mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 18:47:04 +01:00
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:
parent
038017d9eb
commit
0f3ff38788
@ -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
|
||||
|
||||
16
docs/superpowers/contracts/js-anki-deck-2026-10-04.json
Normal file
16
docs/superpowers/contracts/js-anki-deck-2026-10-04.json
Normal 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"
|
||||
}
|
||||
@ -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."
|
||||
]
|
||||
}
|
||||
38
docs/superpowers/evidence/js-anki-deck-2026-10-04.json
Normal file
38
docs/superpowers/evidence/js-anki-deck-2026-10-04.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@ -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
|
||||
|
||||
1
python_pkg/js_anki/__init__.py
Normal file
1
python_pkg/js_anki/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Anki decks for the JS/TS built-ins that LeetCode solutions lean on."""
|
||||
14
python_pkg/js_anki/__main__.py
Normal file
14
python_pkg/js_anki/__main__.py
Normal 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())
|
||||
43
python_pkg/js_anki/array.json
Normal file
43
python_pkg/js_anki/array.json
Normal 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
40
python_pkg/js_anki/cli.py
Normal 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
108
python_pkg/js_anki/deck.py
Normal 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
|
||||
27
python_pkg/js_anki/extras.json
Normal file
27
python_pkg/js_anki/extras.json
Normal 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."}
|
||||
]
|
||||
79
python_pkg/js_anki/models.py
Normal file
79
python_pkg/js_anki/models.py
Normal 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
|
||||
20
python_pkg/js_anki/runner.js
Normal file
20
python_pkg/js_anki/runner.js
Normal 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));
|
||||
});
|
||||
34
python_pkg/js_anki/string.json
Normal file
34
python_pkg/js_anki/string.json
Normal 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"}
|
||||
]
|
||||
0
python_pkg/js_anki/tests/__init__.py
Normal file
0
python_pkg/js_anki/tests/__init__.py
Normal file
30
python_pkg/js_anki/tests/test_cli.py
Normal file
30
python_pkg/js_anki/tests/test_cli.py
Normal 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
|
||||
66
python_pkg/js_anki/tests/test_deck.py
Normal file
66
python_pkg/js_anki/tests/test_deck.py
Normal 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 <it>" 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"]
|
||||
29
python_pkg/js_anki/tests/test_main_entry.py
Normal file
29
python_pkg/js_anki/tests/test_main_entry.py
Normal 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()
|
||||
68
python_pkg/js_anki/tests/test_models.py
Normal file
68
python_pkg/js_anki/tests/test_models.py
Normal 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
|
||||
69
python_pkg/js_anki/tests/test_verify.py
Normal file
69
python_pkg/js_anki/tests/test_verify.py
Normal 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")
|
||||
58
python_pkg/js_anki/verify.py
Normal file
58
python_pkg/js_anki/verify.py
Normal 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))
|
||||
Loading…
Reference in New Issue
Block a user