Add js_anki: verified Anki decks for JS Array/String/Map/Set methods

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-04 22:44:18 +02:00
parent 038017d9eb
commit 0f3ff38788
21 changed files with 743 additions and 44 deletions

View File

@ -270,6 +270,8 @@ repos:
# artgate computes image gates over pixel arrays. Same reason again:
# it is the first package here to import numpy.
- numpy
# js_anki builds the Anki .apkg; same isolated-venv reason as above.
- genanki
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.1.0#subdirectory=gatelock
- anthropic
- rich

View File

@ -0,0 +1,16 @@
{
"title": "js_anki: verified Anki deck for JS built-ins",
"objective": "A single command builds an .apkg of flip-and-self-grade cards for LeetCode-relevant JS Array/String methods and related idioms, where every card example has been executed in Node and matches its stated result.",
"acceptance_criteria": [
"python -m python_pkg.js_anki build exits 0 and writes the .apkg",
"A deliberately wrong expected value makes the build exit non-zero",
"The .apkg imports into desktop Anki with the expected card count and renders correctly",
"100% line and branch coverage and a clean pre-commit run"
],
"out_of_scope": [
"Type-in-answer cards",
"Committing the .apkg binary",
"Changes to other python_pkg packages"
],
"verifier": "python -m python_pkg.js_anki build; pytest python_pkg/js_anki --cov-branch; pre-commit run"
}

View File

@ -1,44 +0,0 @@
{
"intent": "Delete the dead .hippo/ memory store: 161 tracked files and 3.2M of episodic entries for a tool that is no longer installed and whose entries were never once retrieved.",
"scope": [
".hippo/ — 161 tracked files, removed entirely",
"NON-GOAL: any behaviour change; nothing reads this directory"
],
"changes": [
"git rm -r .hippo and removed it from disk. No .gitignore entry was needed after all: the handoff anticipated one for hippo.db (untracked only because *.db is ignored), but with the whole directory gone there is nothing left to ignore."
],
"verification": [
{
"command": "grep -rl '\"retrieval_count\": *[1-9]' .hippo",
"result": "pass",
"evidence": "No matches — every one of the 158 episodic entries had retrieval_count 0. The store was written to and never read from."
},
{
"command": "git log -1 --format=%cs -- .hippo",
"result": "pass",
"evidence": "2026-05-28 — last write was almost three months ago."
},
{
"command": "command -v hippo",
"result": "pass",
"evidence": "Not installed; nothing can write to or read this store."
},
{
"command": "grep -rln 'hippo' --exclude-dir=.git --exclude-dir=.hippo .",
"result": "pass",
"evidence": "Only prose references in the handoff and evidence artifacts. The one code hit, digital_wellbeing/leechblock_defaults.json, is the blocked domain 'wordhippo' — a coincidental substring, not a dependency."
},
{
"command": "git ls-files | wc -l",
"result": "pass",
"evidence": "1346 -> 1175 tracked files."
}
],
"risks": [
"Irreversible only in the sense that any deletion is; the full contents remain in git history and can be restored with git checkout <prev-sha> -- .hippo."
],
"rollback": [
"git revert this commit, or git checkout 309f4c8d -- .hippo to restore the directory alone.",
"After rollback validate: git ls-files | wc -l returns to 1336."
]
}

View File

@ -0,0 +1,38 @@
{
"intent": "Generate a verified Anki .apkg deck so JS/TS Array, String, Map/Set/Object/Math and sort/destructuring idioms can be memorized for LeetCode.",
"scope": [
"New package python_pkg/js_anki (models, verify, deck, cli, runner.js, 3 JSON data files, tests)",
"genanki added to meta/requirements-test.txt and the pylint hook additional_dependencies",
"Non-goals: no type-in cards, no add-ons, .apkg is a gitignored build artifact"
],
"changes": [
"98 entries -> 199 flip/self-grade cards (task, behaviour, argument-order) in JS::Array, JS::String and JS::Extras subdecks plus ::Rare tiers",
"Every example is executed in Node during the build and the build exits 1 if any result differs from the stated one",
"Deterministic model/deck ids and note GUIDs so re-importing updates cards instead of duplicating"
],
"verification": [
{
"command": "PYTHONPATH=. python3 -m python_pkg.js_anki build",
"result": "pass",
"evidence": "verified 98 examples; wrote 199 cards"
},
{
"command": "pytest python_pkg/js_anki --cov=python_pkg.js_anki --cov-branch",
"result": "pass",
"evidence": "24 passed, 100% line and branch coverage"
},
{
"command": "manual: anki js_methods.apkg",
"result": "pass",
"evidence": "User imported into desktop Anki and confirmed the cards look ok"
}
],
"risks": [
"Node must be on PATH to build the deck",
"LeetCode's Node version may lack ES2023 methods (toSorted, findLast) on older runtimes"
],
"rollback": [
"git revert the commit; delete python_pkg/js_anki/build/",
"Remove the imported JS deck from Anki"
]
}

View File

@ -25,6 +25,7 @@ anthropic==1.11.0
beautifulsoup4==4.15.0
coverage==7.16.2
defusedxml==0.7.1
genanki==0.13.1
markdownify==1.2.3
numpy==2.5.3
pillow==12.3.0

View File

@ -0,0 +1 @@
"""Anki decks for the JS/TS built-ins that LeetCode solutions lean on."""

View File

@ -0,0 +1,14 @@
"""Module entry point for the JS-methods Anki deck builder.
Usage:
PYTHONPATH=~/src/testsAndMisc python3 -m python_pkg.js_anki build
"""
from __future__ import annotations
import sys
from python_pkg.js_anki.cli import main
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,43 @@
[
{"method":"push","tier":"core","task":"Append x to the END of arr","call":"arr.push(x)","mutates":true,"returns":"the NEW LENGTH (not the array)","example":"const a=[1,2]; const r=a.push(3); return [r,a];","expected":"[3,[1,2,3]]"},
{"method":"pop","tier":"core","task":"Remove and get the LAST element of arr","call":"arr.pop()","mutates":true,"returns":"the removed element (undefined if empty)","example":"const a=[1,2,3]; const r=a.pop(); return [r,a];","expected":"[3,[1,2]]"},
{"method":"unshift","tier":"core","task":"Add x to the START of arr","call":"arr.unshift(x)","mutates":true,"returns":"the NEW LENGTH","example":"const a=[2,3]; const r=a.unshift(1); return [r,a];","expected":"[3,[1,2,3]]","gotcha":"O(n): every element shifts. Don't use in hot loops."},
{"method":"shift","tier":"core","task":"Remove and get the FIRST element of arr","call":"arr.shift()","mutates":true,"returns":"the removed element (undefined if empty)","example":"const a=[1,2,3]; const r=a.shift(); return [r,a];","expected":"[1,[2,3]]","gotcha":"O(n). A BFS queue via shift() is fine on LeetCode sizes, but use an index pointer for big inputs."},
{"method":"splice","tier":"core","task":"Remove k elements at index i and/or insert items there, in place","call":"arr.splice(i, k, ...items)","mutates":true,"returns":"ARRAY of the removed elements","signature":"arr.splice(start, deleteCount, ...itemsToInsert)","example":"const a=[1,2,3,4]; const r=a.splice(1,2,9); return [r,a];","expected":"[[2,3],[1,9,4]]","gotcha":"Insert only: arr.splice(i, 0, x). Second arg is a COUNT, not an end index."},
{"method":"slice","tier":"core","task":"Copy arr[i..j) into a new array","call":"arr.slice(i, j)","mutates":false,"returns":"new array (shallow copy); end is exclusive","signature":"arr.slice(start, end) // end exclusive, negatives count from the end, slice() = shallow copy","example":"return [[1,2,3,4].slice(1,3),[1,2,3,4].slice(-2)];","expected":"[[2,3],[3,4]]"},
{"method":"concat","tier":"core","task":"Join arrays into a new array","call":"a.concat(b, c)","mutates":false,"returns":"new array","example":"return [1].concat([2],[3,4]);","expected":"[1,2,3,4]","gotcha":"[...a, ...b] does the same."},
{"method":"indexOf","tier":"core","task":"Find the index of x in arr (-1 if absent)","call":"arr.indexOf(x)","mutates":false,"returns":"first index, or -1","example":"return [[1,2,3].indexOf(2),[1,2,3].indexOf(9)];","expected":"[1,-1]","gotcha":"Uses ===, so it can't find NaN; includes() can."},
{"method":"lastIndexOf","tier":"core","task":"Find the LAST index of x in arr","call":"arr.lastIndexOf(x)","mutates":false,"returns":"last index, or -1","example":"return [1,2,1].lastIndexOf(1);","expected":"2"},
{"method":"includes","tier":"core","task":"Check whether arr contains x","call":"arr.includes(x)","mutates":false,"returns":"boolean","example":"return [[1,2,3].includes(2),[NaN].includes(NaN)];","expected":"[true,true]"},
{"method":"find","tier":"core","task":"Get the first element matching a predicate","call":"arr.find((x) => x > 2)","mutates":false,"returns":"the element, or undefined","example":"return [1,2,3,4].find((x)=>x>2);","expected":"3"},
{"method":"findIndex","tier":"core","task":"Get the INDEX of the first element matching a predicate","call":"arr.findIndex((x) => x > 2)","mutates":false,"returns":"index, or -1","example":"return [1,2,3,4].findIndex((x)=>x>2);","expected":"2"},
{"method":"findLast","tier":"core","task":"Get the LAST element matching a predicate","call":"arr.findLast((x) => x < 3)","mutates":false,"returns":"the element, or undefined","example":"return [1,2,3,4].findLast((x)=>x<3);","expected":"2"},
{"method":"filter","tier":"core","task":"Keep only elements matching a predicate","call":"arr.filter((x) => x % 2 === 0)","mutates":false,"returns":"new array","example":"return [1,2,3,4].filter((x)=>x%2===0);","expected":"[2,4]"},
{"method":"map","tier":"core","task":"Transform every element into a new array","call":"arr.map((x, i) => x * 2)","mutates":false,"returns":"new array of same length","example":"return [1,2,3].map((x)=>x*2);","expected":"[2,4,6]","gotcha":"['1','2','3'].map(parseInt) is [1,NaN,NaN] — map passes the index as the 2nd arg. Use map(Number)."},
{"method":"reduce","tier":"core","task":"Fold an array into one value (e.g. a sum)","call":"arr.reduce((acc, x) => acc + x, 0)","mutates":false,"returns":"the final accumulator","signature":"arr.reduce((acc, cur, idx, arr) => newAcc, initialValue)","example":"return [1,2,3].reduce((acc,x)=>acc+x,0);","expected":"6","gotcha":"Without an initial value an empty array throws TypeError."},
{"method":"forEach","tier":"core","task":"Run a side effect for every element","call":"arr.forEach((x, i) => { ... })","mutates":false,"returns":"undefined (not chainable)","example":"const out=[]; [1,2,3].forEach((x)=>out.push(x*2)); return out;","expected":"[2,4,6]","gotcha":"Can't break/return early. Use for...of when you need that."},
{"method":"some","tier":"core","task":"Check whether ANY element matches","call":"arr.some((x) => x > 2)","mutates":false,"returns":"boolean (short-circuits)","example":"return [[1,2,3].some((x)=>x>2),[].some((x)=>x>2)];","expected":"[true,false]"},
{"method":"every","tier":"core","task":"Check whether ALL elements match","call":"arr.every((x) => x > 0)","mutates":false,"returns":"boolean (short-circuits)","example":"return [[1,2,3].every((x)=>x>0),[].every((x)=>x>0)];","expected":"[true,true]","gotcha":"Empty array → true."},
{"method":"sort","tier":"core","task":"Sort numbers ascending (in place)","call":"arr.sort((a, b) => a - b)","mutates":true,"returns":"the SAME array (sorted)","signature":"arr.sort((a, b) => a - b) // ascending; b - a = descending","example":"const a=[10,9,1]; const d=[...a].sort(); a.sort((x,y)=>x-y); return [d,a];","expected":"[[1,10,9],[1,9,10]]","gotcha":"Default sort is LEXICOGRAPHIC: [10,9,1].sort() → [1,10,9]."},
{"method":"reverse","tier":"core","task":"Reverse an array in place","call":"arr.reverse()","mutates":true,"returns":"the SAME array (reversed)","example":"const a=[1,2,3]; const r=a.reverse(); return [r,a,r===a];","expected":"[[3,2,1],[3,2,1],true]","gotcha":"Need a copy? toReversed() or [...arr].reverse()."},
{"method":"join","tier":"core","task":"Join array elements into a string","call":"arr.join(sep)","mutates":false,"returns":"string","example":"return [1,2,3].join('-');","expected":"\"1-2-3\"","gotcha":"Default separator is a comma, not empty. Use join('') to concatenate."},
{"method":"flat","tier":"core","task":"Flatten nested arrays (any depth)","call":"arr.flat(Infinity)","mutates":false,"returns":"new array; default depth is 1","example":"return [1,[2,[3]]].flat(Infinity);","expected":"[1,2,3]"},
{"method":"flatMap","tier":"core","task":"Map each element to several and flatten one level","call":"arr.flatMap((x) => [x, x * 2])","mutates":false,"returns":"new array","example":"return [1,2].flatMap((x)=>[x,x*2]);","expected":"[1,2,2,4]"},
{"method":"fill","tier":"core","task":"Create an array of n zeros","call":"new Array(n).fill(0)","mutates":true,"returns":"the SAME array (filled)","signature":"arr.fill(value, start, end)","example":"return new Array(3).fill(0);","expected":"[0,0,0]","gotcha":"fill([]) / fill({}) shares ONE reference across all slots. Use Array.from for a 2D grid."},
{"method":"Array.from","tier":"core","task":"Create an array of length n from a function of the index","call":"Array.from({ length: n }, (_, i) => i * i)","mutates":false,"returns":"new array","signature":"Array.from(iterableOrArrayLike, mapFn)","example":"return Array.from({length:3},(_,i)=>i*i);","expected":"[0,1,4]","gotcha":"Also converts a Set/Map/string: Array.from(set)."},
{"method":"Array.from (2D grid)","tier":"core","task":"Create an m×n grid of zeros (rows must be independent)","call":"Array.from({ length: m }, () => Array(n).fill(0))","mutates":false,"returns":"new array of independent rows","example":"const g=Array.from({length:2},()=>Array(3).fill(0)); g[0][0]=1; return g;","expected":"[[1,0,0],[0,0,0]]","gotcha":"Array(m).fill(Array(n).fill(0)) makes every row the SAME array."},
{"method":"Array.isArray","tier":"core","task":"Check whether a value is an array","call":"Array.isArray(x)","mutates":false,"returns":"boolean","example":"return [Array.isArray([]),Array.isArray('a')];","expected":"[true,false]","gotcha":"typeof [] is 'object', so typeof can't tell."},
{"method":"at","tier":"core","task":"Get the LAST element (or the nth from the end)","call":"arr.at(-1)","mutates":false,"returns":"element, or undefined","example":"return [[1,2,3].at(-1),[1,2,3].at(0)];","expected":"[3,1]","gotcha":"arr[-1] is undefined — only at() takes negatives."},
{"method":"entries","tier":"core","task":"Loop with both index and value","call":"for (const [i, x] of arr.entries()) { ... }","mutates":false,"returns":"iterator of [index, value] pairs","example":"return [...['a','b'].entries()];","expected":"[[0,\"a\"],[1,\"b\"]]"},
{"method":"length","tier":"core","task":"Empty an array in place","call":"arr.length = 0","mutates":true,"returns":"(assignment) — array is truncated","example":"const a=[1,2,3]; a.length=0; return a;","expected":"[]","gotcha":"Aliases see the change; arr = [] only rebinds the variable."},
{"method":"toSorted","tier":"core","task":"Get a sorted COPY (don't touch the original)","call":"arr.toSorted((a, b) => a - b)","mutates":false,"returns":"new sorted array","example":"const a=[3,1,2]; return [a.toSorted((x,y)=>x-y),a];","expected":"[[1,2,3],[3,1,2]]","gotcha":"ES2023; same lexicographic default as sort()."},
{"method":"toReversed","tier":"core","task":"Get a reversed COPY (don't touch the original)","call":"arr.toReversed()","mutates":false,"returns":"new reversed array","example":"const a=[1,2,3]; return [a.toReversed(),a];","expected":"[[3,2,1],[1,2,3]]"},
{"method":"findLastIndex","tier":"rare","task":"Get the INDEX of the last element matching a predicate","call":"arr.findLastIndex((x) => x < 3)","mutates":false,"returns":"index, or -1","example":"return [1,2,3,4].findLastIndex((x)=>x<3);","expected":"1"},
{"method":"reduceRight","tier":"rare","task":"Fold an array from right to left","call":"arr.reduceRight((acc, x) => acc + x, '')","mutates":false,"returns":"the final accumulator","example":"return ['a','b','c'].reduceRight((acc,x)=>acc+x,'');","expected":"\"cba\""},
{"method":"copyWithin","tier":"rare","task":"Copy part of the array onto another part of itself, in place","call":"arr.copyWithin(target, start, end)","mutates":true,"returns":"the SAME array","signature":"arr.copyWithin(target, start, end)","example":"return [1,2,3,4,5].copyWithin(0,3);","expected":"[4,5,3,4,5]"},
{"method":"keys","tier":"rare","task":"Iterate over the indices of an array","call":"arr.keys()","mutates":false,"returns":"iterator of indices","example":"return [...['a','b','c'].keys()];","expected":"[0,1,2]"},
{"method":"values","tier":"rare","task":"Iterate over the values of an array as an iterator","call":"arr.values()","mutates":false,"returns":"iterator of values","example":"return [...['a','b'].values()];","expected":"[\"a\",\"b\"]"},
{"method":"toSpliced","tier":"rare","task":"Get a COPY with items removed/inserted (splice without mutation)","call":"arr.toSpliced(i, k, ...items)","mutates":false,"returns":"new array","signature":"arr.toSpliced(start, deleteCount, ...items)","example":"const a=[1,2,3,4]; return [a.toSpliced(1,2,9),a];","expected":"[[1,9,4],[1,2,3,4]]"},
{"method":"with","tier":"rare","task":"Get a COPY with one index replaced","call":"arr.with(i, v)","mutates":false,"returns":"new array","example":"const a=[1,2,3]; return [a.with(1,9),a];","expected":"[[1,9,3],[1,2,3]]"},
{"method":"Array.of","tier":"rare","task":"Create an array from the given arguments","call":"Array.of(7)","mutates":false,"returns":"new array","example":"return [Array.of(7),Array(3).length];","expected":"[[7],3]","gotcha":"Array(7) makes 7 empty slots; Array.of(7) makes [7]."}
]

40
python_pkg/js_anki/cli.py Normal file
View File

@ -0,0 +1,40 @@
"""CLI: verify every example in Node, then write the Anki .apkg."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
from python_pkg.js_anki.deck import build_package
from python_pkg.js_anki.models import load_all
from python_pkg.js_anki.verify import VerificationError, verify
DEFAULT_OUT = Path(__file__).parent / "build" / "js_methods.apkg"
def build_parser() -> argparse.ArgumentParser:
"""Argument parser with a single `build` subcommand."""
parser = argparse.ArgumentParser(prog="js_anki", description=__doc__)
sub = parser.add_subparsers(dest="cmd", required=True)
build = sub.add_parser("build", help="verify examples and write the .apkg")
build.add_argument("--out", type=Path, default=DEFAULT_OUT)
return parser
def main(argv: list[str] | None = None) -> int:
"""Run the CLI; non-zero exit if any example fails verification."""
args = build_parser().parse_args(argv)
entries = load_all()
try:
verify(entries)
except VerificationError as exc:
sys.stderr.write(f"verification FAILED:\n{exc}\n")
return 1
package, count = build_package(entries)
args.out.parent.mkdir(parents=True, exist_ok=True)
package.write_to_file(str(args.out))
sys.stdout.write(
f"verified {len(entries)} examples; wrote {count} cards to {args.out}\n"
)
return 0

108
python_pkg/js_anki/deck.py Normal file
View File

@ -0,0 +1,108 @@
"""Turn verified entries into a genanki package (flip + self-grade cards)."""
from __future__ import annotations
import hashlib
from html import escape
from typing import TYPE_CHECKING
import genanki
if TYPE_CHECKING:
from python_pkg.js_anki.models import Entry
_ID_SPACE = 1 << 31
CSS = """
.card { font-family: system-ui, sans-serif; font-size: 20px; text-align: left;
color: #e6e6e6; background: #1b1d21; padding: 12px; }
pre { background: #25282e; padding: 10px; border-radius: 6px;
overflow-x: auto; white-space: pre-wrap; }
code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 17px; }
.gotcha { color: #f0b429; margin-top: 10px; }
.res { color: #7bd88f; }
"""
def stable_id(key: str) -> int:
"""Deterministic 31-bit id so re-imports update instead of duplicating."""
return int(hashlib.sha256(key.encode()).hexdigest()[:8], 16) % _ID_SPACE
def build_model() -> genanki.Model:
"""The single two-field note type shared by all cards."""
return genanki.Model(
stable_id("js_anki:model:v1"),
"JS method (js_anki)",
fields=[{"name": "Front"}, {"name": "Back"}],
templates=[
{
"name": "Card",
"qfmt": "{{Front}}",
"afmt": '{{FrontSide}}<hr id="answer">{{Back}}',
}
],
css=CSS,
)
def deck_name(entry: Entry) -> str:
"""Subdeck for an entry: JS::Array, JS::Array::Rare, JS::Extras, ..."""
base = f"JS::{entry.family.capitalize()}"
if entry.family != "extras" and entry.tier == "rare":
return f"{base}::Rare"
return base
def _code(text: str) -> str:
return f"<pre><code>{escape(text)}</code></pre>"
def _gotcha(entry: Entry) -> str:
return f'<div class="gotcha">⚠ {escape(entry.gotcha)}</div>' if entry.gotcha else ""
def cards_for(entry: Entry) -> list[tuple[str, str, str]]:
"""Return (kind, front, back) triples for one entry."""
name = f"<code>{escape(entry.method)}</code>"
task_back = (
f"{_code(entry.call)}{_code(entry.example)}"
f'<div class="res">→ {escape(entry.expected)}</div>{_gotcha(entry)}'
)
cards = [("task", escape(entry.task), task_back)]
if entry.mutates is not None:
mut = (
"Yes — mutates the original" if entry.mutates else "No — original untouched"
)
cards.append(
(
"behaviour",
f"{name}: does it mutate the original, and what does it return?",
f"<b>{mut}</b><br>Returns: {escape(entry.returns)}{_gotcha(entry)}",
)
)
if entry.signature:
cards.append(
("args", f"{name}: argument order / signature?", _code(entry.signature))
)
return cards
def build_package(entries: list[Entry]) -> tuple[genanki.Package, int]:
"""Build the .apkg package; returns (package, card_count)."""
model = build_model()
decks: dict[str, genanki.Deck] = {}
count = 0
for entry in entries:
name = deck_name(entry)
deck = decks.setdefault(name, genanki.Deck(stable_id(f"deck:{name}"), name))
for kind, front, back in cards_for(entry):
deck.add_note(
genanki.Note(
model=model,
fields=[front, back],
tags=[entry.family, entry.tier],
guid=genanki.guid_for("js_anki", entry.uid, kind),
)
)
count += 1
return genanki.Package(list(decks.values())), count

View File

@ -0,0 +1,27 @@
[
{"method":"Map get/set/has","tier":"core","task":"Store and read key/value pairs in a Map","call":"m.set(k, v); m.get(k); m.has(k); m.size","mutates":true,"returns":"set() returns the Map (chainable); get() returns undefined if missing","example":"const m=new Map(); m.set('a',1); return [m.get('a'),m.get('b'),m.has('a'),m.size];","expected":"[1,null,true,1]","gotcha":"size is a property, not a method. Map keeps insertion order and allows any key type."},
{"method":"Map delete","tier":"core","task":"Delete a key from a Map","call":"m.delete(k)","mutates":true,"returns":"true if the key existed, else false","example":"const m=new Map([['a',1]]); return [m.delete('a'),m.delete('a'),m.size];","expected":"[true,false,0]"},
{"method":"Map counter","tier":"core","task":"Count frequencies with a Map","call":"m.set(c, (m.get(c) ?? 0) + 1)","mutates":true,"returns":"(idiom)","example":"const m=new Map(); for (const c of 'aab') m.set(c,(m.get(c)??0)+1); return [...m];","expected":"[[\"a\",2],[\"b\",1]]"},
{"method":"Map iterate","tier":"core","task":"Loop over a Map's key/value pairs","call":"for (const [k, v] of m) { ... } // m.keys(), m.values() too","returns":"iterates [key, value] pairs in insertion order","example":"const out=[]; for (const [k,v] of new Map([['a',1],['b',2]])) out.push(k+v); return out;","expected":"[\"a1\",\"b2\"]"},
{"method":"Set basics","tier":"core","task":"Track unique values in a Set","call":"const s = new Set(arr); s.add(x); s.has(x); s.delete(x); s.size","mutates":true,"returns":"add() returns the Set; has()/delete() return boolean","example":"const s=new Set([1,2,2,3]); s.add(4); return [s.size,s.has(2),s.delete(9)];","expected":"[4,true,false]"},
{"method":"Set dedupe","tier":"core","task":"Remove duplicates from an array","call":"[...new Set(arr)]","mutates":false,"returns":"new array, first-seen order","example":"return [...new Set([3,1,3,2,1])];","expected":"[3,1,2]"},
{"method":"Object.entries","tier":"core","task":"Get [key, value] pairs of an object","call":"Object.entries(obj) // Object.keys / Object.values","mutates":false,"returns":"array of [key, value]","example":"return Object.entries({a:1,b:2});","expected":"[[\"a\",1],[\"b\",2]]","gotcha":"Integer-like keys come first in ascending order, regardless of insertion."},
{"method":"Object.fromEntries","tier":"core","task":"Build an object from [key, value] pairs","call":"Object.fromEntries(pairs)","mutates":false,"returns":"new object","example":"return Object.fromEntries([['a',1],['b',2]]);","expected":"{\"a\":1,\"b\":2}","gotcha":"Works with a Map too: Object.fromEntries(map)."},
{"method":"Object counter","tier":"core","task":"Count frequencies with a plain object","call":"o[c] = (o[c] || 0) + 1","mutates":true,"returns":"(idiom)","example":"const o={}; for (const c of 'aab') o[c]=(o[c]||0)+1; return o;","expected":"{\"a\":2,\"b\":1}","gotcha":"Keys become strings; prefer Map when keys are numbers or you need .size."},
{"method":"Math.max / Math.min","tier":"core","task":"Max / min of an array","call":"Math.max(...arr)","mutates":false,"returns":"number; Math.max() is -Infinity, Math.min() is Infinity","example":"return [Math.max(...[1,5,3]),Math.min(...[1,5,3])];","expected":"[5,1]","gotcha":"Spread of ~100k+ items overflows the stack; use a loop or reduce."},
{"method":"Infinity sentinel","tier":"core","task":"Initialize a running min / max","call":"let best = Infinity; // or -Infinity for max","returns":"(idiom)","example":"let best=Infinity; for (const x of [4,2,8]) best=Math.min(best,x); return best;","expected":"2"},
{"method":"Math.floor / trunc","tier":"core","task":"Integer division (and negatives)","call":"Math.floor(a / b) // Math.trunc rounds toward 0","returns":"number","example":"return [Math.floor(-7/2),Math.trunc(-7/2),Math.floor(7/2)];","expected":"[-4,-3,3]","gotcha":"Floor and trunc differ for negatives. Math.round(-2.5) is -2 (ties go toward +∞)."},
{"method":"Math.ceil / round / abs / sqrt / pow","tier":"core","task":"Ceil, round, absolute value, square root, power","call":"Math.ceil(x); Math.round(x); Math.abs(x); Math.sqrt(x); x ** n","returns":"number","example":"return [Math.ceil(2.1),Math.round(2.5),Math.abs(-3),Math.sqrt(16),2**10];","expected":"[3,3,3,4,1024]"},
{"method":"parseInt / Number","tier":"core","task":"Convert a string to a number","call":"parseInt(s, 10) // Number(s) for strict","returns":"number (NaN on failure)","example":"return [parseInt('42px',10),String(Number('42px'))];","expected":"[42,\"NaN\"]","gotcha":"parseInt is lenient (stops at the first bad char); Number is strict. Always pass the radix."},
{"method":"toString(radix) / parseInt(s, radix)","tier":"core","task":"Convert between decimal and binary (or another base)","call":"n.toString(2); parseInt(s, 2)","returns":"string / number","example":"return [(5).toString(2),parseInt('101',2)];","expected":"[\"101\",5]"},
{"method":"Bit operations","tier":"core","task":"Shift, mask, XOR, and shift-left bit tricks","call":"n >> 1; n & 1; a ^ b; 1 << k","returns":"32-bit signed integer","example":"return [7>>1,7&1,5^3,1<<3];","expected":"[3,1,6,8]","gotcha":"Bitwise ops work on 32-bit signed ints: above 2^31 use BigInt. >>> 0 gives unsigned."},
{"method":"Sort numbers asc/desc","tier":"core","task":"Sort numbers ascending and descending","call":"arr.sort((a, b) => a - b) // desc: b - a","returns":"the SAME array, sorted in place","example":"const a=[3,1,2]; return [[...a].sort((x,y)=>x-y),[...a].sort((x,y)=>y-x)];","expected":"[[1,2,3],[3,2,1]]","gotcha":"The comparator returns a number, not a boolean: (a, b) => a > b misbehaves."},
{"method":"Sort by two keys","tier":"core","task":"Sort pairs by the first value, then by the second","call":"arr.sort((a, b) => a[0] - b[0] || a[1] - b[1])","returns":"the SAME array, sorted in place","example":"return [[2,1],[1,5],[1,2]].sort((a,b)=>a[0]-b[0]||a[1]-b[1]);","expected":"[[1,2],[1,5],[2,1]]","gotcha":"0 is falsy, so || falls through to the next key only on ties."},
{"method":"Sort strings by length","tier":"core","task":"Sort strings by length","call":"arr.sort((a, b) => a.length - b.length)","returns":"the SAME array, sorted in place","example":"return ['ccc','a','bb'].sort((a,b)=>a.length-b.length);","expected":"[\"a\",\"bb\",\"ccc\"]"},
{"method":"Swap with destructuring","tier":"core","task":"Swap two variables (or array slots) without a temp","call":"[a, b] = [b, a] // [arr[i], arr[j]] = [arr[j], arr[i]]","returns":"(idiom)","example":"let a=1,b=2; [a,b]=[b,a]; return [a,b];","expected":"[2,1]","gotcha":"The previous line must end with a semicolon or ASI joins them."},
{"method":"Array destructuring + rest","tier":"core","task":"Split off the first element and keep the rest","call":"const [first, ...rest] = arr","returns":"(idiom)","example":"const [first,...rest]=[1,2,3]; return [first,rest];","expected":"[1,[2,3]]"},
{"method":"Spread copy","tier":"core","task":"Copy an array / append without mutating","call":"const b = [...a, x]","mutates":false,"returns":"new array (SHALLOW copy)","example":"const a=[1,2]; const b=[...a,3]; return [b,a];","expected":"[[1,2,3],[1,2]]","gotcha":"Nested arrays are still shared. For 2D grids: grid.map((r) => [...r])."},
{"method":"Object destructuring + rest","tier":"core","task":"Pull one field out of an object and keep the rest","call":"const { a, ...rest } = obj","returns":"(idiom)","example":"const {a,...r}={a:1,b:2,c:3}; return [a,r];","expected":"[1,{\"b\":2,\"c\":3}]"},
{"method":"structuredClone","tier":"core","task":"Deep-copy a nested array/object","call":"structuredClone(x)","mutates":false,"returns":"deep copy","example":"const a=[[1]]; const b=structuredClone(a); b[0][0]=9; return a;","expected":"[[1]]","gotcha":"Throws on functions. JSON.parse(JSON.stringify(x)) loses undefined/Map/Set."},
{"method":"?? vs ||","tier":"core","task":"Default a value only when null/undefined (not for 0)","call":"x ?? fallback","returns":"x unless it is null/undefined","example":"return [0||5,0??5];","expected":"[5,0]","gotcha":"|| also replaces 0, '' and false; ?? only replaces null and undefined."}
]

View File

@ -0,0 +1,79 @@
"""Typed card-entry model and JSON loader for the JS-method data files."""
from __future__ import annotations
from dataclasses import dataclass
import json
from pathlib import Path
from typing import Any
DATA_DIR = Path(__file__).parent
FAMILIES = ("array", "string", "extras")
TIERS = frozenset({"core", "rare"})
_REQUIRED = ("method", "tier", "task", "call", "returns", "example", "expected")
@dataclass(frozen=True)
class Entry:
"""One method/idiom and everything the cards are generated from.
`example` is a JS function body ending in `return`; `expected` is the exact
`JSON.stringify` of that return value (or `undefined`).
"""
family: str
method: str
tier: str
task: str
call: str
returns: str
example: str
expected: str
mutates: bool | None = None
signature: str = ""
gotcha: str = ""
@property
def uid(self) -> str:
"""Stable identity used for GUIDs and verification ids."""
return f"{self.family}:{self.method}"
def parse_entry(family: str, raw: dict[str, Any]) -> Entry:
"""Validate one raw JSON object and build an Entry."""
missing = [k for k in _REQUIRED if not raw.get(k)]
if missing:
msg = f"{family}: entry {raw.get('method', '?')!r} missing {missing}"
raise ValueError(msg)
if raw["tier"] not in TIERS:
msg = f"{family}:{raw['method']}: tier must be one of {sorted(TIERS)}"
raise ValueError(msg)
unknown = set(raw) - set(_REQUIRED) - {"mutates", "signature", "gotcha"}
if unknown:
msg = f"{family}:{raw['method']}: unknown keys {sorted(unknown)}"
raise ValueError(msg)
return Entry(
family=family,
**{k: raw[k] for k in _REQUIRED},
mutates=raw.get("mutates"),
signature=raw.get("signature", ""),
gotcha=raw.get("gotcha", ""),
)
def load_family(family: str, data_dir: Path = DATA_DIR) -> list[Entry]:
"""Load and validate `<family>.json`."""
raw = json.loads((data_dir / f"{family}.json").read_text(encoding="utf-8"))
return [parse_entry(family, item) for item in raw]
def load_all(data_dir: Path = DATA_DIR) -> list[Entry]:
"""Load every family; reject duplicate identities."""
entries = [e for fam in FAMILIES for e in load_family(fam, data_dir)]
seen: set[str] = set()
for entry in entries:
if entry.uid in seen:
msg = f"duplicate entry {entry.uid}"
raise ValueError(msg)
seen.add(entry.uid)
return entries

View File

@ -0,0 +1,20 @@
// Evaluates each card example and reports its JSON.stringify'd return value.
// Input (stdin): [{"id": "...", "code": "<function body with return>"}]
// Output (stdout): [{"id": "...", "ok": true, "value": "..."} | {"ok": false, "error": "..."}]
"use strict";
const chunks = [];
process.stdin.on("data", (c) => chunks.push(c));
process.stdin.on("end", () => {
const items = JSON.parse(Buffer.concat(chunks).toString("utf8"));
const out = items.map(({ id, code }) => {
try {
const result = new Function(code)();
const value = result === undefined ? "undefined" : JSON.stringify(result);
return { id, ok: true, value };
} catch (err) {
return { id, ok: false, error: String(err) };
}
});
process.stdout.write(JSON.stringify(out));
});

View File

@ -0,0 +1,34 @@
[
{"method":"charAt / s[i]","tier":"core","task":"Get the character at index i","call":"s[i] // or s.charAt(i)","mutates":false,"returns":"1-char string; s[i] gives undefined out of range, charAt gives ''","example":"return ['abc'[1],'abc'.charAt(5),'abc'[5]];","expected":"[\"b\",\"\",null]","gotcha":"Strings are immutable: s[0] = 'x' silently does nothing."},
{"method":"charCodeAt","tier":"core","task":"Get the char code of the character at index i","call":"s.charCodeAt(i)","mutates":false,"returns":"number (UTF-16 code unit)","example":"return ['a'.charCodeAt(0),'c'.charCodeAt(0)-'a'.charCodeAt(0)];","expected":"[97,2]","gotcha":"Alphabet index idiom: s.charCodeAt(i) - 97."},
{"method":"String.fromCharCode","tier":"core","task":"Turn a char code into a character","call":"String.fromCharCode(code)","mutates":false,"returns":"string","example":"return String.fromCharCode(97+2);","expected":"\"c\"","gotcha":"Static method on String, not on a string value."},
{"method":"split","tier":"core","task":"Split a string into an array by a separator","call":"s.split(sep)","mutates":false,"returns":"array of strings","signature":"s.split(separator, limit) // split('') = chars","example":"return ['a,b'.split(','),'abc'.split('')];","expected":"[[\"a\",\"b\"],[\"a\",\"b\",\"c\"]]"},
{"method":"[...s]","tier":"core","task":"Turn a string into an array of characters","call":"[...s] // or Array.from(s)","mutates":false,"returns":"new array of characters","example":"return [...'abc'];","expected":"[\"a\",\"b\",\"c\"]","gotcha":"Unlike split(''), spread keeps emoji/surrogate pairs whole."},
{"method":"reverse a string","tier":"core","task":"Reverse a string","call":"[...s].reverse().join('')","mutates":false,"returns":"new string","example":"return [...'abc'].reverse().join('');","expected":"\"cba\"","gotcha":"Strings have no reverse(); go via an array."},
{"method":"slice","tier":"core","task":"Get s[i..j) as a substring","call":"s.slice(i, j)","mutates":false,"returns":"new string; end exclusive","signature":"s.slice(start, end) // end exclusive, negatives count from the end","example":"return ['abcdef'.slice(1,3),'abcdef'.slice(-2)];","expected":"[\"bc\",\"ef\"]"},
{"method":"substring","tier":"core","task":"Substring by two indices (and how it differs from slice)","call":"s.substring(i, j)","mutates":false,"returns":"new string; end exclusive","signature":"s.substring(start, end) // swaps args if start > end, negatives → 0","example":"return ['abcdef'.substring(3,1),'abcdef'.slice(3,1),'abcdef'.substring(-2)];","expected":"[\"bc\",\"\",\"abcdef\"]","gotcha":"Prefer slice(): substring swaps arguments and treats negatives as 0."},
{"method":"indexOf","tier":"core","task":"Find the index of a substring (-1 if absent)","call":"s.indexOf(sub)","mutates":false,"returns":"first index, or -1","signature":"s.indexOf(searchString, fromIndex)","example":"return ['hello'.indexOf('l'),'hello'.indexOf('z')];","expected":"[2,-1]"},
{"method":"includes","tier":"core","task":"Check whether a string contains a substring","call":"s.includes(sub)","mutates":false,"returns":"boolean","example":"return 'hello'.includes('ell');","expected":"true","gotcha":"Case-sensitive; a RegExp argument throws."},
{"method":"startsWith","tier":"core","task":"Check whether a string starts with a prefix","call":"s.startsWith(prefix)","mutates":false,"returns":"boolean","example":"return 'hello'.startsWith('he');","expected":"true"},
{"method":"endsWith","tier":"core","task":"Check whether a string ends with a suffix","call":"s.endsWith(suffix)","mutates":false,"returns":"boolean","example":"return 'hello'.endsWith('lo');","expected":"true"},
{"method":"toLowerCase","tier":"core","task":"Lowercase a string","call":"s.toLowerCase()","mutates":false,"returns":"new string","example":"return 'aBc'.toLowerCase();","expected":"\"abc\""},
{"method":"toUpperCase","tier":"core","task":"Uppercase a string","call":"s.toUpperCase()","mutates":false,"returns":"new string","example":"return 'aBc'.toUpperCase();","expected":"\"ABC\""},
{"method":"trim","tier":"core","task":"Strip whitespace from both ends","call":"s.trim()","mutates":false,"returns":"new string","example":"return ' a b '.trim();","expected":"\"a b\""},
{"method":"repeat","tier":"core","task":"Repeat a string n times","call":"s.repeat(n)","mutates":false,"returns":"new string","example":"return 'ab'.repeat(3);","expected":"\"ababab\"","gotcha":"Negative n throws RangeError."},
{"method":"padStart","tier":"core","task":"Left-pad a string to a length (e.g. zero-pad)","call":"s.padStart(len, '0')","mutates":false,"returns":"new string","signature":"s.padStart(targetLength, padString)","example":"return '5'.padStart(3,'0');","expected":"\"005\""},
{"method":"padEnd","tier":"core","task":"Right-pad a string to a length","call":"s.padEnd(len, ' ')","mutates":false,"returns":"new string","signature":"s.padEnd(targetLength, padString)","example":"return 'ab'.padEnd(5,'.');","expected":"\"ab...\""},
{"method":"replace","tier":"core","task":"Replace occurrences of a pattern (first only vs all)","call":"s.replace(pattern, replacement)","mutates":false,"returns":"new string","signature":"s.replace(patternOrString, replacementOrFn)","example":"return ['aaa'.replace('a','b'),'aaa'.replace(/a/g,'b')];","expected":"[\"baa\",\"bbb\"]","gotcha":"A string pattern replaces only the FIRST match. Use /x/g or replaceAll."},
{"method":"replaceAll","tier":"core","task":"Replace ALL occurrences of a substring","call":"s.replaceAll(sub, replacement)","mutates":false,"returns":"new string","signature":"s.replaceAll(patternOrString, replacementOrFn)","example":"return 'aaa'.replaceAll('a','b');","expected":"\"bbb\"","gotcha":"A RegExp argument must have the g flag or it throws."},
{"method":"at","tier":"core","task":"Get the LAST character (or nth from the end)","call":"s.at(-1)","mutates":false,"returns":"1-char string, or undefined","example":"return ['abc'.at(-1),'abc'.at(0)];","expected":"[\"c\",\"a\"]"},
{"method":"match","tier":"core","task":"Find all regex matches in a string","call":"s.match(/\\d+/g)","mutates":false,"returns":"array of matches, or null if none","example":"return ['a1b22'.match(/\\d+/g),'abc'.match(/\\d+/g)];","expected":"[[\"1\",\"22\"],null]","gotcha":"Without the g flag you get a single match object, not all matches. null → use ?? []."},
{"method":"matchAll","tier":"core","task":"Iterate all regex matches including capture groups","call":"[...s.matchAll(/(\\d)(\\w)/g)]","mutates":false,"returns":"iterator of match arrays","example":"return [...'1a2b'.matchAll(/(\\d)(\\w)/g)].map((m)=>m[1]+m[2]);","expected":"[\"1a\",\"2b\"]","gotcha":"The regex must have the g flag or it throws."},
{"method":"trimStart","tier":"rare","task":"Strip whitespace from the START only","call":"s.trimStart()","mutates":false,"returns":"new string","example":"return ' a '.trimStart();","expected":"\"a \""},
{"method":"trimEnd","tier":"rare","task":"Strip whitespace from the END only","call":"s.trimEnd()","mutates":false,"returns":"new string","example":"return ' a '.trimEnd();","expected":"\" a\""},
{"method":"lastIndexOf","tier":"rare","task":"Find the LAST index of a substring","call":"s.lastIndexOf(sub)","mutates":false,"returns":"last index, or -1","example":"return 'hello'.lastIndexOf('l');","expected":"3"},
{"method":"substr","tier":"rare","task":"Substring by start index and LENGTH (deprecated)","call":"s.substr(start, length)","mutates":false,"returns":"new string","signature":"s.substr(start, length) // deprecated; use slice","example":"return 'abcdef'.substr(1,3);","expected":"\"bcd\"","gotcha":"Second arg is a length, not an end index. Deprecated."},
{"method":"concat","tier":"rare","task":"Concatenate strings with a method call","call":"s.concat(a, b)","mutates":false,"returns":"new string","example":"return 'a'.concat('b','c');","expected":"\"abc\"","gotcha":"Just use + or a template literal."},
{"method":"search","tier":"rare","task":"Find the index of a regex match","call":"s.search(/c/)","mutates":false,"returns":"index, or -1","example":"return 'abc'.search(/c/);","expected":"2"},
{"method":"localeCompare","tier":"rare","task":"Compare two strings for sorting","call":"a.localeCompare(b)","mutates":false,"returns":"negative / 0 / positive number","example":"return ['b','a','c'].sort((x,y)=>x.localeCompare(y));","expected":"[\"a\",\"b\",\"c\"]","gotcha":"Locale-aware and slower; for plain code-unit order use a < b ? -1 : 1."},
{"method":"codePointAt","tier":"rare","task":"Get the full Unicode code point at index i","call":"s.codePointAt(i)","mutates":false,"returns":"number","example":"return '😀'.codePointAt(0);","expected":"128512","gotcha":"charCodeAt would return only half of the surrogate pair."},
{"method":"normalize","tier":"rare","task":"Normalize Unicode (e.g. strip accents with NFD)","call":"s.normalize('NFD')","mutates":false,"returns":"new string","example":"return 'é'.normalize('NFD').length;","expected":"2"}
]

View File

View File

@ -0,0 +1,30 @@
"""Tests for python_pkg.js_anki.cli."""
from __future__ import annotations
from pathlib import Path
from unittest import mock
import pytest
from python_pkg.js_anki.cli import main
from python_pkg.js_anki.verify import VerificationError
def test_build_writes_apkg(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
out = tmp_path / "sub" / "deck.apkg"
assert main(["build", "--out", str(out)]) == 0
assert out.stat().st_size > 0
assert "cards to" in capsys.readouterr().out
def test_build_fails_on_verification_error(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
out = tmp_path / "deck.apkg"
with mock.patch(
"python_pkg.js_anki.cli.verify", side_effect=VerificationError("bad")
):
assert main(["build", "--out", str(out)]) == 1
assert not out.exists()
assert "verification FAILED" in capsys.readouterr().err

View File

@ -0,0 +1,66 @@
"""Tests for python_pkg.js_anki.deck."""
from __future__ import annotations
import dataclasses
from python_pkg.js_anki.deck import (
build_package,
cards_for,
deck_name,
stable_id,
)
from python_pkg.js_anki.models import Entry
BASE = Entry(
family="array",
method="m<b>",
tier="core",
task="do <it>",
call="a.m()",
returns="thing",
example="return 1;",
expected="1",
)
def test_stable_id_deterministic() -> None:
assert stable_id("x") == stable_id("x") != stable_id("y")
def test_deck_names() -> None:
assert deck_name(BASE) == "JS::Array"
assert deck_name(dataclasses.replace(BASE, tier="rare")) == "JS::Array::Rare"
assert (
deck_name(dataclasses.replace(BASE, family="extras", tier="rare"))
== "JS::Extras"
)
def test_task_card_only_and_escaping() -> None:
cards = cards_for(BASE)
assert [k for k, _, _ in cards] == ["task"]
assert "do &lt;it&gt;" in cards[0][1]
assert "gotcha" not in cards[0][2]
def test_behaviour_card_mutating_and_pure() -> None:
mut = cards_for(dataclasses.replace(BASE, mutates=True, gotcha="careful"))
assert [k for k, _, _ in mut] == ["task", "behaviour"]
assert "Yes" in mut[1][2]
assert "careful" in mut[1][2]
pure = cards_for(dataclasses.replace(BASE, mutates=False))
assert "No" in pure[1][2]
def test_args_card() -> None:
cards = cards_for(dataclasses.replace(BASE, signature="m(a, b)"))
assert [k for k, _, _ in cards] == ["task", "args"]
assert "m(a, b)" in cards[1][2]
def test_build_package_counts_and_groups() -> None:
rare = dataclasses.replace(BASE, method="r", tier="rare", mutates=True)
package, count = build_package([BASE, rare])
assert count == 3
assert sorted(d.name for d in package.decks) == ["JS::Array", "JS::Array::Rare"]

View File

@ -0,0 +1,29 @@
"""Tests for js_anki.__main__ module."""
from __future__ import annotations
import runpy
import sys
from unittest.mock import MagicMock, patch
import pytest
def test_main_called_when_run_as_module() -> None:
mock_main = MagicMock(return_value=0)
sys.modules.pop("python_pkg.js_anki.__main__", None)
with (
patch("python_pkg.js_anki.cli.main", mock_main),
pytest.raises(SystemExit) as excinfo,
):
runpy.run_module("python_pkg.js_anki", run_name="__main__")
assert excinfo.value.code == 0
mock_main.assert_called_once()
def test_main_not_called_on_plain_import() -> None:
mock_main = MagicMock()
sys.modules.pop("python_pkg.js_anki.__main__", None)
with patch("python_pkg.js_anki.cli.main", mock_main):
__import__("python_pkg.js_anki.__main__")
mock_main.assert_not_called()

View File

@ -0,0 +1,68 @@
"""Tests for python_pkg.js_anki.models."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from python_pkg.js_anki.models import FAMILIES, load_all, load_family, parse_entry
RAW = {
"method": "push",
"tier": "core",
"task": "t",
"call": "c",
"returns": "r",
"example": "return 1;",
"expected": "1",
}
def test_parse_entry_defaults() -> None:
entry = parse_entry("array", RAW)
assert entry.uid == "array:push"
assert entry.mutates is None
assert not entry.signature
assert not entry.gotcha
def test_parse_entry_optional_fields() -> None:
entry = parse_entry(
"array", {**RAW, "mutates": True, "signature": "s", "gotcha": "g"}
)
assert (entry.mutates, entry.signature, entry.gotcha) == (True, "s", "g")
def test_missing_required_key() -> None:
with pytest.raises(ValueError, match="missing"):
parse_entry("array", {k: v for k, v in RAW.items() if k != "call"})
def test_bad_tier() -> None:
with pytest.raises(ValueError, match="tier"):
parse_entry("array", {**RAW, "tier": "hard"})
def test_unknown_key() -> None:
with pytest.raises(ValueError, match="unknown keys"):
parse_entry("array", {**RAW, "typo": 1})
def test_load_family_reads_json(tmp_path: Path) -> None:
(tmp_path / "array.json").write_text(json.dumps([RAW]), encoding="utf-8")
assert [e.method for e in load_family("array", tmp_path)] == ["push"]
def test_load_all_rejects_duplicates(tmp_path: Path) -> None:
for fam in FAMILIES:
data = [RAW, RAW] if fam == "array" else []
(tmp_path / f"{fam}.json").write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(ValueError, match="duplicate"):
load_all(tmp_path)
def test_shipped_data_loads() -> None:
entries = load_all()
assert len(entries) > 90

View File

@ -0,0 +1,69 @@
"""Tests for python_pkg.js_anki.verify."""
from __future__ import annotations
import dataclasses
import subprocess
from unittest import mock
import pytest
from python_pkg.js_anki.models import Entry
from python_pkg.js_anki.verify import VerificationError, run_examples, verify
ENTRY = Entry(
family="array",
method="m",
tier="core",
task="t",
call="c",
returns="r",
example="return [1,2];",
expected="[1,2]",
)
def test_verify_passes_with_real_node() -> None:
verify(
[
ENTRY,
dataclasses.replace(
ENTRY, method="u", example="return;", expected="undefined"
),
]
)
def test_verify_reports_mismatch() -> None:
with pytest.raises(VerificationError, match="expected 9"):
verify([dataclasses.replace(ENTRY, expected="9")])
def test_verify_reports_js_error() -> None:
with pytest.raises(VerificationError, match="error"):
verify([dataclasses.replace(ENTRY, example="throw new Error('x');")])
def test_verify_reports_missing_result() -> None:
with (
mock.patch("python_pkg.js_anki.verify.run_examples", return_value={}),
pytest.raises(VerificationError, match="no result"),
):
verify([ENTRY])
def test_node_missing() -> None:
with (
mock.patch("python_pkg.js_anki.verify.shutil.which", return_value=None),
pytest.raises(VerificationError, match="node not found"),
):
run_examples([ENTRY])
def test_node_failure_exit_code() -> None:
failed = subprocess.CompletedProcess([], 1, stdout="", stderr="boom")
with (
mock.patch("python_pkg.js_anki.verify.subprocess.run", return_value=failed),
pytest.raises(VerificationError, match="boom"),
):
run_examples([ENTRY], node="node")

View File

@ -0,0 +1,58 @@
"""Run every card example in Node and compare with the stated result."""
from __future__ import annotations
import json
from pathlib import Path
import shutil
import subprocess
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from python_pkg.js_anki.models import Entry
RUNNER = Path(__file__).with_name("runner.js")
class VerificationError(Exception):
"""Raised when a card example does not produce its stated result."""
def run_examples(
entries: list[Entry], node: str | None = None, timeout: int = 60
) -> dict[str, dict[str, object]]:
"""Execute all examples in one Node process; map uid -> runner result."""
node_bin = node or shutil.which("node")
if not node_bin:
msg = "node not found on PATH"
raise VerificationError(msg)
payload = json.dumps([{"id": e.uid, "code": e.example} for e in entries])
proc = subprocess.run(
[node_bin, str(RUNNER)],
input=payload,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
if proc.returncode != 0:
msg = f"node runner failed: {proc.stderr.strip()}"
raise VerificationError(msg)
return {r["id"]: r for r in json.loads(proc.stdout)}
def verify(entries: list[Entry], node: str | None = None) -> None:
"""Raise VerificationError listing every example that disagrees."""
results = run_examples(entries, node)
problems: list[str] = []
for entry in entries:
res = results.get(entry.uid)
if res is None or not res["ok"]:
err = res["error"] if res else "no result"
problems.append(f"{entry.uid}: error: {err}")
elif res["value"] != entry.expected:
problems.append(
f"{entry.uid}: expected {entry.expected} got {res['value']}"
)
if problems:
raise VerificationError("\n".join(problems))