diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/checks.yml similarity index 70% rename from .github/workflows/pythonpackage.yml rename to .github/workflows/checks.yml index cb3e23425..f3c3a52c8 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/checks.yml @@ -20,11 +20,13 @@ jobs: uses: actions/setup-node@v1 with: node-version: 12 - - name: Install dependencies - run: | - sudo apt install portaudio19-dev - pip install -r requirements.qt + - name: Set up Protoc + uses: Arduino/actions/setup-protoc@master - name: Run checks run: | - make check - + sudo apt install portaudio19-dev + python${{ matrix.python-version }} -m venv ~/pyenv + . ~/pyenv/bin/activate + pip install -r requirements.qt + pip install --upgrade setuptools pip + make check RUSTARGS="" diff --git a/.gitignore b/.gitignore index 16ba7b97c..9c3b0c91f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,15 +3,18 @@ *\# *~ .*.swp -.DS_Store .build .coverage +.DS_Store .mypy_cache .pytype __pycache__ anki/buildhash.py +anki/backend_pb2.* aqt/forms locale +rs/ankirs/src/backend_proto.rs +rs/target tools/runanki.system ts/node_modules web/deckbrowser.js diff --git a/.isort.cfg b/.isort.cfg index d8c44da35..6992ada53 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,5 +1,5 @@ [settings] -skip=aqt/forms +skip=aqt/forms,anki/backend_pb2.py,backend_pb2.pyi multi_line_output=3 include_trailing_comma=True force_grid_wrap=0 diff --git a/Makefile b/Makefile index 07cf42554..ddcf65c08 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,14 @@ PREFIX := /usr SHELL := bash .SHELLFLAGS := -eu -o pipefail -c -.ONESHELL: .DELETE_ON_ERROR: MAKEFLAGS += --warn-undefined-variables MAKEFLAGS += --no-builtin-rules RUNARGS := .SUFFIXES: -BLACKARGS := -t py36 anki aqt +BLACKARGS := -t py36 anki aqt tests +RUSTARGS := --release --strip +ISORTARGS := anki aqt tests $(shell mkdir -p .build) @@ -40,6 +41,8 @@ install: -xdg-mime default anki.desktop application/x-apkg @echo @echo "Install complete." + # fixme: _ankirs.so needs to be copied into system python env or + # 'maturin build' used uninstall: rm -rf ${DESTDIR}${PREFIX}/share/anki @@ -55,20 +58,48 @@ uninstall: # Prerequisites ###################### -RUNREQS := .build/pyrunreqs .build/jsreqs +RUNREQS := .build/py-run-deps .build/ts-deps -.build/pyrunreqs: requirements.txt +# Python prerequisites +###################### + +.build/py-run-deps: requirements.txt pip install -r $< - touch $@ + @touch $@ -.build/pycheckreqs: requirements.check .build/pyrunreqs +.build/py-check-reqs: requirements.check .build/py-run-deps pip install -r $< ./tools/typecheck-setup.sh - touch $@ + @touch $@ -.build/jsreqs: ts/package.json +# TS prerequisites +###################### + +.build/ts-deps: ts/package.json (cd ts && npm i) - touch $@ + @touch $@ + +# Rust prerequisites +###################### + +.build/rust-deps: .build/py-run-deps + pip install maturin + @touch $@ + +RUST_TOOLCHAIN := $(shell cat rs/rust-toolchain) + +.build/rs-fmt-deps: + rustup component add rustfmt-preview --toolchain $(RUST_TOOLCHAIN) + @touch $@ + +.build/rs-clippy-deps: + rustup component add clippy-preview --toolchain $(RUST_TOOLCHAIN) + @touch $@ + +# Protobuf +###################### + +PROTODEPS := $(wildcard proto/*.proto) # Typescript source ###################### @@ -76,18 +107,31 @@ RUNREQS := .build/pyrunreqs .build/jsreqs TSDEPS := $(wildcard ts/src/*.ts) JSDEPS := $(patsubst ts/src/%.ts, web/%.js, $(TSDEPS)) +# Rust source +###################### + +RSDEPS := $(shell find rs -type f | egrep -v 'target|/\.|proto.rs') + # Building ###################### -BUILDDEPS := .build/ui .build/js +BUILDDEPS := .build/ui .build/js .build/rs .build/py-proto .build/ui: $(RUNREQS) $(shell find designer -type f) ./tools/build_ui.sh - touch $@ + @touch $@ -.build/js: .build/jsreqs $(TSDEPS) +.build/js: .build/ts-deps $(TSDEPS) (cd ts && npm run build) - touch $@ + @touch $@ + +.build/rs: .build/rust-deps $(RUNREQS) $(RSDEPS) $(PROTODEPS) + (cd rs/pymod && maturin develop $(RUSTARGS)) + @touch $@ + +.build/py-proto: $(RUNREQS) $(PROTODEPS) + protoc --proto_path=proto --python_out=anki --mypy_out=anki proto/backend.proto + @touch $@ .PHONY: build clean @@ -97,6 +141,7 @@ build: $(BUILDDEPS) clean: rm -rf .build rm -rf $(JSDEPS) + rm -rf rs/target # Running ###################### @@ -109,61 +154,89 @@ run: build ###################### .PHONY: check -check: mypy pyimports pyfmt pytest pylint checkpretty +check: rs-test rs-fmt rs-clippy py-mypy py-test py-fmt py-imports py-lint ts-fmt + +.PHONY: fix +fix: fix-py-fmt fix-py-imports fix-rs-fmt fix-ts-fmt # Checking python ###################### -PYCHECKDEPS := $(BUILDDEPS) .build/pycheckreqs $(shell find anki aqt -name '*.py' | grep -v buildhash.py) +PYCHECKDEPS := $(BUILDDEPS) .build/py-check-reqs $(shell find anki aqt -name '*.py' | grep -v buildhash.py) +PYTESTDEPS := $(wildcard tests/*.py) -.build/mypy: $(PYCHECKDEPS) +.build/py-mypy: $(PYCHECKDEPS) mypy anki aqt - touch $@ + @touch $@ -.build/pytest: $(PYCHECKDEPS) $(wildcard tests/*.py) +.build/py-test: $(PYCHECKDEPS) $(PYTESTDEPS) ./tools/tests.sh - touch $@ + @touch $@ -.build/pylint: $(PYCHECKDEPS) - pylint -j 0 --rcfile=.pylintrc -f colorized --extension-pkg-whitelist=PyQt5 anki aqt - touch $@ +.build/py-lint: $(PYCHECKDEPS) + pylint -j 0 --rcfile=.pylintrc -f colorized --extension-pkg-whitelist=PyQt5,_ankirs anki aqt + @touch $@ -.build/pyimports: $(PYCHECKDEPS) - isort anki aqt --check # if this fails, run 'make fixpyimports' - touch $@ +.build/py-imports: $(PYCHECKDEPS) $(PYTESTDEPS) + isort $(ISORTARGS) --check # if this fails, run 'make fix-py-imports' + @touch $@ -.build/pyfmt: $(PYCHECKDEPS) - black --check $(BLACKARGS) # if this fails, run 'make fixpyfmt' - touch $@ +.build/py-fmt: $(PYCHECKDEPS) $(PYTESTDEPS) + black --check $(BLACKARGS) # if this fails, run 'make fix-py-fmt' + @touch $@ -.PHONY: mypy pytest pylint pyimports pyfmt -mypy: .build/mypy -pytest: .build/pytest -pylint: .build/pylint -pyimports: .build/pyimports -pyfmt: .build/pyfmt +.PHONY: py-mypy py-test py-lint py-imports py-fmt +py-mypy: .build/py-mypy +py-test: .build/py-test +py-lint: .build/py-lint +py-imports: .build/py-imports +py-fmt: .build/py-fmt -.PHONY: fixpyimports fixpyfmt +.PHONY: fix-py-imports fix-py-fmt -fixpyimports: - isort anki aqt +fix-py-imports: + isort $(ISORTARGS) -fixpyfmt: +fix-py-fmt: black $(BLACKARGS) anki aqt +# Checking rust +###################### + +.build/rs-test: $(RSDEPS) + (cd rs/ankirs && cargo test) + @touch $@ + +.build/rs-fmt: .build/rs-fmt-deps $(RSDEPS) + (cd rs && cargo fmt -- --check) # if this fails, run 'make fix-rs-fmt' + @touch $@ + +.build/rs-clippy: .build/rs-clippy-deps $(RSDEPS) + (cd rs && cargo clippy -- -D warnings) + @touch $@ + +.PHONY: rs-test rs-fmt fix-rs-fmt rs-clippy + +rs-test: .build/rs-test +rs-fmt: .build/rs-fmt +rs-clippy: .build/rs-clippy + +fix-rs-fmt: + (cd rs && cargo fmt) + + # Checking typescript ###################### TSCHECKDEPS := $(BUILDDEPS) $(TSDEPS) -.build/checkpretty: $(TSCHECKDEPS) - (cd ts && npm run check-pretty) # if this fails, run 'make pretty' - touch $@ +.build/ts-fmt: $(TSCHECKDEPS) + (cd ts && npm run check-pretty) # if this fails, run 'make fix-ts-fmt' + @touch $@ -.build/pretty: $(TSCHECKDEPS) +.PHONY: fix-ts-fmt ts-fmt +ts-fmt: .build/ts-fmt + +fix-ts-fmt: (cd ts && npm run pretty) - touch $@ -.PHONY: pretty checkpretty -pretty: .build/pretty -checkpretty: .build/checkpretty diff --git a/README.development b/README.development index 8f257c375..91e41b022 100644 --- a/README.development +++ b/README.development @@ -18,6 +18,16 @@ To start, make sure you have the following installed: - mpv - lame - npm + - your platform's C compiler, eg gcc, Xcode or Visual Studio 2017. + - GNU make + - protoc v3 (https://github.com/protocolbuffers/protobuf/releases) + - rustup (https://rustup.rs/) + - pip 19+ + +Next, build a Python virtual environment and activate it: + +$ python3 -m venv ~/pyenv +$ . ~/pyenv/bin/activate If the distro you are using has PyQt5 installed, make sure you have the PyQt5 WebEngine module and development tools (eg pyqt5-dev-tools) installed as well. @@ -49,11 +59,11 @@ Mac users You can use homebrew to install some dependencies: -$ brew install python mpv lame portaudio +$ brew install python mpv lame portaudio protobuf npm rustup-init Windows users -------------- -The build scripts have not been tested on Windows, and you'll find things -easiest if you build Anki using WSL. -https://docs.microsoft.com/en-us/windows/wsl/install-win10 +The build process uses a GNU makefile, so you'll either need to run +GNU make via WSL (https://docs.microsoft.com/en-us/windows/wsl/install-win10) +or Cygwin, or manually execute the build steps. diff --git a/anki/backend.py b/anki/backend.py new file mode 100644 index 000000000..1ce8d66d4 --- /dev/null +++ b/anki/backend.py @@ -0,0 +1,72 @@ +# pylint: skip-file + +from typing import Dict, List + +import _ankirs # pytype: disable=import-error + +import anki.backend_pb2 as pb + +from .types import AllTemplateReqs + + +class BackendException(Exception): + def __str__(self) -> str: + err: pb.BackendError = self.args[0] # pylint: disable=unsubscriptable-object + kind = err.WhichOneof("value") + if kind == "invalid_input": + return f"invalid input: {err.invalid_input.info}" + elif kind == "template_parse": + return f"template parse: {err.template_parse.info}" + else: + return f"unhandled error: {err}" + + +def proto_template_reqs_to_legacy( + reqs: List[pb.TemplateRequirement], +) -> AllTemplateReqs: + legacy_reqs = [] + for (idx, req) in enumerate(reqs): + kind = req.WhichOneof("value") + # fixme: sorting is for the unit tests - should check if any + # code depends on the order + if kind == "any": + legacy_reqs.append((idx, "any", sorted(req.any.ords))) + elif kind == "all": + legacy_reqs.append((idx, "all", sorted(req.all.ords))) + else: + l: List[int] = [] + legacy_reqs.append((idx, "none", l)) + return legacy_reqs + + +class Backend: + def __init__(self): + self._backend = _ankirs.Backend() + + def _run_command(self, input: pb.BackendInput) -> pb.BackendOutput: + input_bytes = input.SerializeToString() + output_bytes = self._backend.command(input_bytes) + output = pb.BackendOutput() + output.ParseFromString(output_bytes) + kind = output.WhichOneof("value") + if kind == "error": + raise BackendException(output.error) + else: + return output + + def plus_one(self, num: int) -> int: + input = pb.BackendInput(plus_one=pb.PlusOneIn(num=num)) + output = self._run_command(input) + return output.plus_one.num + + def template_requirements( + self, template_fronts: List[str], field_map: Dict[str, int] + ) -> AllTemplateReqs: + input = pb.BackendInput( + template_requirements=pb.TemplateRequirementsIn( + template_front=template_fronts, field_names_to_ordinals=field_map + ) + ) + output = self._run_command(input).template_requirements + reqs: List[pb.TemplateRequirement] = output.requirements # type: ignore + return proto_template_reqs_to_legacy(reqs) diff --git a/anki/collection.py b/anki/collection.py index 8eb74b15e..775b1457b 100644 --- a/anki/collection.py +++ b/anki/collection.py @@ -19,6 +19,7 @@ import anki.find import anki.latex # sets up hook import anki.notes import anki.template +from anki.backend import Backend from anki.cards import Card from anki.consts import * from anki.db import DB @@ -84,8 +85,12 @@ class _Collection: ls: int conf: Dict[str, Any] _undo: List[Any] + backend: Backend - def __init__(self, db: DB, server: bool = False, log: bool = False) -> None: + def __init__( + self, db: DB, backend: Backend, server: bool = False, log: bool = False + ) -> None: + self.backend = backend self._debugLog = log self.db = db self.path = db._path diff --git a/anki/decks.py b/anki/decks.py index 041d98d49..97916b4b2 100644 --- a/anki/decks.py +++ b/anki/decks.py @@ -8,6 +8,7 @@ import operator import unicodedata from typing import Any, Dict, List, Optional, Set, Tuple, Union +import anki # pylint: disable=unused-import from anki.consts import * from anki.errors import DeckRenameError from anki.hooks import runHook @@ -98,7 +99,7 @@ class DeckManager: # Registry save/load ############################################################# - def __init__(self, col) -> None: + def __init__(self, col: "anki.storage._Collection") -> None: self.col = col self.decks = {} self.dconf = {} diff --git a/anki/find.py b/anki/find.py index 8cd15654b..bfb4375de 100644 --- a/anki/find.py +++ b/anki/find.py @@ -608,9 +608,7 @@ def findDupes(col, fieldName, search="") -> List[Tuple[Any, List]]: # empty does not count as duplicate if not val: continue - if val not in vals: - vals[val] = [] - vals[val].append(nid) + vals.setdefault(val, []).append(nid) if len(vals[val]) == 2: dupes.append((val, vals[val])) return dupes diff --git a/anki/models.py b/anki/models.py index 89f091d54..04d21c383 100644 --- a/anki/models.py +++ b/anki/models.py @@ -556,6 +556,9 @@ select id from notes where mid = ?)""" ########################################################################## def _updateRequired(self, m: NoteType) -> None: + self._updateRequiredNew(m) + + def _updateRequiredLegacy(self, m: NoteType) -> None: if m["type"] == MODEL_CLOZE: # nothing to do return @@ -566,6 +569,14 @@ select id from notes where mid = ?)""" req.append([t["ord"], ret[0], ret[1]]) m["req"] = req + def _updateRequiredNew(self, m: NoteType) -> None: + fronts = [t["qfmt"] for t in m["tmpls"]] + field_map = {} + for (idx, fld) in enumerate(m["flds"]): + field_map[fld["name"]] = idx + reqs = self.col.backend.template_requirements(fronts, field_map) + m["req"] = [list(l) for l in reqs] + def _reqForTemplate( self, m: NoteType, flds: List[str], t: Template ) -> Tuple[Union[str, List[int]], ...]: diff --git a/anki/notes.py b/anki/notes.py index 26055a2aa..5b26249dc 100644 --- a/anki/notes.py +++ b/anki/notes.py @@ -4,6 +4,7 @@ from typing import Any, List, Optional, Tuple +import anki # pylint: disable=unused-import from anki.utils import ( fieldChecksum, guid64, @@ -19,7 +20,10 @@ class Note: tags: List[str] def __init__( - self, col, model: Optional[Any] = None, id: Optional[int] = None + self, + col: "anki.storage._Collection", + model: Optional[Any] = None, + id: Optional[int] = None, ) -> None: assert not (model and id) self.col = col diff --git a/anki/storage.py b/anki/storage.py index 5855a1f95..259661897 100644 --- a/anki/storage.py +++ b/anki/storage.py @@ -8,6 +8,7 @@ import os import re from typing import Any, Dict, Tuple +from anki.backend import Backend from anki.collection import _Collection from anki.consts import * from anki.db import DB @@ -26,6 +27,10 @@ def Collection( path: str, lock: bool = True, server: bool = False, log: bool = False ) -> _Collection: "Open a new or existing collection. Path must be unicode." + backend = Backend() + # fixme: this call is temporarily here to ensure the brige is working + # on all platforms, and should be removed in a future beta + assert backend.plus_one(5) == 6 assert path.endswith(".anki2") path = os.path.abspath(path) create = not os.path.exists(path) @@ -46,7 +51,7 @@ def Collection( db.execute("pragma journal_mode = wal") db.setAutocommit(False) # add db to col and do any remaining upgrades - col = _Collection(db, server, log) + col = _Collection(db, backend=backend, server=server, log=log) if ver < SCHEMA_VERSION: _upgrade(col, ver) elif ver > SCHEMA_VERSION: @@ -60,7 +65,11 @@ def Collection( addBasicModel(col) col.save() if lock: - col.lock() + try: + col.lock() + except: + col.db.close() + raise return col diff --git a/anki/tags.py b/anki/tags.py index d0730b381..33c528131 100644 --- a/anki/tags.py +++ b/anki/tags.py @@ -14,6 +14,7 @@ import json import re from typing import Callable, Dict, List, Tuple +import anki # pylint: disable=unused-import from anki.hooks import runHook from anki.utils import ids2str, intTime @@ -23,7 +24,7 @@ class TagManager: # Registry save/load ############################################################# - def __init__(self, col) -> None: + def __init__(self, col: "anki.storage._Collection") -> None: self.col = col self.tags: Dict[str, int] = {} diff --git a/anki/template/template.py b/anki/template/template.py index 6bb61cf2f..9be7d9dcc 100644 --- a/anki/template/template.py +++ b/anki/template/template.py @@ -210,14 +210,15 @@ class Template: return "{unknown field %s}" % tag_name return txt + @classmethod def clozeText(self, txt: str, ord: str, type: str) -> str: - """Processes the given Cloze deletion within the given template.""" + """Processe the given Cloze deletion within the given template.""" reg = clozeReg currentRegex = clozeReg % ord if not re.search(currentRegex, txt): # No Cloze deletion was found in txt. return "" - txt = self._removeFormattingFromMathjax(txt, ord) + txt = cls._removeFormattingFromMathjax(txt, ord) def repl(m): # replace chosen cloze with type @@ -237,7 +238,8 @@ class Template: # and display other clozes normally return re.sub(reg % r"\d+", "\\2", txt) - def _removeFormattingFromMathjax(self, txt, ord) -> str: + @classmethod + def _removeFormattingFromMathjax(cls, txt, ord) -> str: """Marks all clozes within MathJax to prevent formatting them. Active Cloze deletions within MathJax should not be wrapped inside diff --git a/anki/types.py b/anki/types.py index cdf742296..19395eaa2 100644 --- a/anki/types.py +++ b/anki/types.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Tuple, Union +from typing import Any, Dict, List, Tuple, Union # Model attributes are stored in a dict keyed by strings. This type alias # provides more descriptive function signatures than just 'Dict[str, Any]' @@ -31,3 +31,8 @@ QAData = Tuple[ # Corresponds to 'cardFlags' column. TODO: document int, ] + +TemplateRequirementType = str # Union["all", "any", "none"] +# template ordinal, type, list of field ordinals +TemplateRequiredFieldOrds = Tuple[int, TemplateRequirementType, List[int]] +AllTemplateReqs = List[TemplateRequiredFieldOrds] diff --git a/aqt/__init__.py b/aqt/__init__.py index 49ac71d49..f55769f60 100644 --- a/aqt/__init__.py +++ b/aqt/__init__.py @@ -332,11 +332,19 @@ def _run(argv=None, exec=True): opts, args = parseArgs(argv) # profile manager - pm = ProfileManager(opts.base) - pmLoadResult = pm.setupMeta() + pm = None + try: + pm = ProfileManager(opts.base) + pmLoadResult = pm.setupMeta() + except: + # will handle below + pass - # gl workarounds - setupGL(pm) + if pm: + # gl workarounds + setupGL(pm) + # apply user-provided scale factor + os.environ["QT_SCALE_FACTOR"] = str(pm.uiScale()) # opt in to full hidpi support? if not os.environ.get("ANKI_NOHIGHDPI"): @@ -348,9 +356,6 @@ def _run(argv=None, exec=True): if os.environ.get("ANKI_SOFTWAREOPENGL"): QCoreApplication.setAttribute(Qt.AA_UseSoftwareOpenGL) - # apply user-provided scale factor - os.environ["QT_SCALE_FACTOR"] = str(pm.uiScale()) - # create the app QCoreApplication.setApplicationName("Anki") QGuiApplication.setDesktopFileName("anki.desktop") @@ -359,6 +364,16 @@ def _run(argv=None, exec=True): # we've signaled the primary instance, so we should close return + if not pm: + QMessageBox.critical( + None, + "Error", + """\ +Anki could not create its data folder. Please see the File Locations \ +section of the manual, and ensure that location is not read-only.""", + ) + return + # disable icons on mac; this must be done before window created if isMac: app.setAttribute(Qt.AA_DontShowIconsInMenus) @@ -392,12 +407,14 @@ environment points to a valid, writable folder.""", ) return + if pmLoadResult.firstTime: + pm.setDefaultLang() + if pmLoadResult.loadError: QMessageBox.warning( None, "Preferences Corrupt", - """\ - Anki's prefs21.db file was corrupt and has been recreated. If you were using multiple \ + """Anki's prefs21.db file was corrupt and has been recreated. If you were using multiple \ profiles, please add them back using the same names to recover your cards.""", ) diff --git a/aqt/about.py b/aqt/about.py index 29b29c0f7..41e180725 100644 --- a/aqt/about.py +++ b/aqt/about.py @@ -148,6 +148,7 @@ system. It's free and open source." "David Bailey", "Arman High", "Arthur Milchior", + "Rai (Michael Pokorny)", ) ) diff --git a/aqt/profiles.py b/aqt/profiles.py index 64980feb3..1b3f7e619 100644 --- a/aqt/profiles.py +++ b/aqt/profiles.py @@ -97,22 +97,7 @@ class ProfileManager: ###################################################################### def ensureBaseExists(self): - try: - self._ensureExists(self.base) - except: - # can't translate, as lang not initialized, and qt may not be - print("unable to create base folder") - QMessageBox.critical( - None, - "Error", - """\ -Anki could not create the folder %s. Please ensure that location is not \ -read-only and you have permission to write to it. If you cannot fix this \ -issue, please see the documentation for information on running Anki from \ -a flash drive.""" - % self.base, - ) - raise + self._ensureExists(self.base) # Folder migration ###################################################################### @@ -385,7 +370,6 @@ create table if not exists profiles "insert or replace into profiles values ('_global', ?)", self._pickle(metaConf), ) - self._setDefaultLang() return result def _ensureProfile(self): @@ -409,7 +393,7 @@ please see: ###################################################################### # On first run, allow the user to choose the default language - def _setDefaultLang(self): + def setDefaultLang(self): # create dialog class NoCloseDiag(QDialog): def reject(self): @@ -452,7 +436,7 @@ please see: None, "Anki", en % name, QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) if r != QMessageBox.Yes: - return self._setDefaultLang() + return self.setDefaultLang() self.setLang(code) def setLang(self, code): diff --git a/aqt/webview.py b/aqt/webview.py index e398baadf..19211efcb 100644 --- a/aqt/webview.py +++ b/aqt/webview.py @@ -416,11 +416,16 @@ body {{ zoom: {}; background: {}; {} }} self.evalWithCallback("$(document.body).height()", self._onHeight) def _onHeight(self, qvar): + from aqt import mw + if qvar is None: - from aqt import mw mw.progress.timer(1000, mw.reset, False) return - height = math.ceil(qvar * self.zoomFactor()) + scaleFactor = self.zoomFactor() + if scaleFactor == 1: + scaleFactor = mw.pm.uiScale() + + height = math.ceil(qvar * scaleFactor) self.setFixedHeight(height) diff --git a/mypy.ini b/mypy.ini index 4f76feffc..91e7b46c6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -34,3 +34,5 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-jsonschema.*] ignore_missing_imports = True +[mypy-_ankirs] +ignore_missing_imports = True diff --git a/proto/backend.proto b/proto/backend.proto new file mode 100644 index 000000000..f8a0a61a3 --- /dev/null +++ b/proto/backend.proto @@ -0,0 +1,68 @@ +syntax = "proto3"; + +package backend_proto; + +message Empty {} + +message BackendInput { + oneof value { + PlusOneIn plus_one = 2; + TemplateRequirementsIn template_requirements = 3; + } +} + +message BackendOutput { + oneof value { + BackendError error = 1; + PlusOneOut plus_one = 2; + TemplateRequirementsOut template_requirements = 3; + } +} + +message BackendError { + oneof value { + InvalidInputError invalid_input = 1; + TemplateParseError template_parse = 2; + } +} + +message InvalidInputError { + string info = 1; +} + +message PlusOneIn { + int32 num = 1; +} + +message PlusOneOut { + int32 num = 1; +} + +message TemplateParseError { + string info = 1; +} + +message TemplateRequirementsIn { + repeated string template_front = 1; + map field_names_to_ordinals = 2; +} + +message TemplateRequirementsOut { + repeated TemplateRequirement requirements = 1; +} + +message TemplateRequirement { + oneof value { + TemplateRequirementAll all = 1; + TemplateRequirementAny any = 2; + Empty none = 3; + } +} + +message TemplateRequirementAll { + repeated uint32 ords = 1; +} + +message TemplateRequirementAny { + repeated uint32 ords = 1; +} diff --git a/requirements.check b/requirements.check index aee07e685..291acc797 100644 --- a/requirements.check +++ b/requirements.check @@ -1,6 +1,5 @@ -nose +nose2 mock -mypy==0.750 # fixme: when isort 5.0 is released, switch to pypy git+https://github.com/dae/isort#egg=isort # fixme: when pylint supports isort 5.0, switch to pypy diff --git a/requirements.txt b/requirements.txt index 3dd8678ba..a4618cd26 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,7 @@ jsonschema psutil; sys_platform == "win32" distro; sys_platform != "win32" and sys_platform != "darwin" typing +protobuf +mypy==0.750 +mypy_protobuf + diff --git a/rs/Cargo.lock b/rs/Cargo.lock new file mode 100644 index 000000000..f1f3c14db --- /dev/null +++ b/rs/Cargo.lock @@ -0,0 +1,783 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +[[package]] +name = "aho-corasick" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fb5e95d83b38284460a5fda7d6470aa0b8844d283a0b614b8535e880800d2d" +dependencies = [ + "memchr", +] + +[[package]] +name = "ankirs" +version = "0.1.0" +dependencies = [ + "bytes", + "failure", + "nom", + "prost", + "prost-build", +] + +[[package]] +name = "arrayvec" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9fd44efafa8690358b7408d253adf110036b88f55672a933f01d616ad9b1b9" +dependencies = [ + "nodrop", +] + +[[package]] +name = "autocfg" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d49d90015b3c36167a20fe2810c5cd875ad504b39cff3d4eae7977e6b7c1cb2" + +[[package]] +name = "backtrace" +version = "0.3.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924c76597f0d9ca25d762c25a4d369d51267536465dc5064bdf0eb073ed477ea" +dependencies = [ + "backtrace-sys", + "cfg-if", + "libc", + "rustc-demangle", +] + +[[package]] +name = "backtrace-sys" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6575f128516de27e3ce99689419835fce9643a9b215a14d2b5b685be018491" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "byteorder" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c3dd8985a7111efc5c80b44e23ecdd8c007de8ade3b96595387e812b957cf5" + +[[package]] +name = "bytes" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206fdffcfa2df7cbe15601ef46c813fce0965eb3286db6b56c583b814b51c81c" +dependencies = [ + "byteorder", + "iovec", +] + +[[package]] +name = "c2-chacha" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214238caa1bf3a496ec3392968969cab8549f96ff30652c9e56885329315f6bb" +dependencies = [ + "ppv-lite86", +] + +[[package]] +name = "cc" +version = "1.0.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52a465a666ca3d838ebbf08b241383421412fe7ebb463527bba275526d89f76" + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + +[[package]] +name = "ctor" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8ce37ad4184ab2ce004c33bf6379185d3b1c95801cab51026bd271bf68eedc" +dependencies = [ + "quote 1.0.2", + "syn 1.0.11", +] + +[[package]] +name = "either" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1f6b1ce1c140482ea30ddd3335fc0024ac7ee112895426e0a629a6c20adfe3" + +[[package]] +name = "failure" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8273f13c977665c5db7eb2b99ae520952fe5ac831ae4cd09d80c4c7042b5ed9" +dependencies = [ + "backtrace", + "failure_derive", +] + +[[package]] +name = "failure_derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc225b78e0391e4b8683440bf2e63c2deeeb2ce5189eab46e2b68c6d3725d08" +dependencies = [ + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", + "synstructure", +] + +[[package]] +name = "fixedbitset" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86d4de0081402f5e88cdac65c8dcdcc73118c1a7a465e2a05f0da05843a8ea33" + +[[package]] +name = "getrandom" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7db7ca94ed4cd01190ceee0d8a8052f08a247aa1b469a7f68c6a3b71afcf407" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "ghost" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a36606a68532b5640dc86bb1f33c64b45c4682aad4c50f3937b317ea387f3d6" +dependencies = [ + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", +] + +[[package]] +name = "heck" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20564e78d53d2bb135c343b3f47714a56af2061f1c928fdb541dc7b9fdd94205" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "indoc" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9553c1e16c114b8b77ebeb329e5f2876eed62a8d51178c8bc6bff0d65f98f8" +dependencies = [ + "indoc-impl", + "proc-macro-hack", +] + +[[package]] +name = "indoc-impl" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b714fc08d0961716390977cdff1536234415ac37b509e34e5a983def8340fb75" +dependencies = [ + "proc-macro-hack", + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", + "unindent", +] + +[[package]] +name = "inventory" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4cece20baea71d9f3435e7bbe9adf4765f091c5fe404975f844006964a71299" +dependencies = [ + "ctor", + "ghost", + "inventory-impl", +] + +[[package]] +name = "inventory-impl" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2869bf972e998977b1cb87e60df70341d48e48dca0823f534feb91ea44adaf9" +dependencies = [ + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", +] + +[[package]] +name = "iovec" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" +dependencies = [ + "libc", +] + +[[package]] +name = "itertools" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f56a2d0bc861f9165be4eb3442afd3c236d8a98afd426f65d92324ae1091a484" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501266b7edd0174f8530248f87f99c88fbe60ca4ef3dd486835b8d8d53136f7f" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "lexical-core" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304bccb228c4b020f3a4835d247df0a02a7c4686098d4167762cfbbe4c5cb14" +dependencies = [ + "arrayvec", + "cfg-if", + "rustc_version", + "ryu", + "static_assertions", +] + +[[package]] +name = "libc" +version = "0.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d515b1f41455adea1313a4a2ac8a8a477634fbae63cc6100e3aebb207ce61558" + +[[package]] +name = "log" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b6052be84e6b71ab17edffc2eeabf5c2c3ae1fdb464aae35ac50c67a44e1f7" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "memchr" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88579771288728879b57485cc7d6b07d648c9f0141eb955f8ab7f9d45394468e" + +[[package]] +name = "multimap" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04b9f127583ed176e163fb9ec6f3e793b87e21deedd5734a69386a18a0151" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c618b63422da4401283884e6668d39f819a106ef51f5f59b81add00075da35ca" +dependencies = [ + "lexical-core", + "memchr", + "version_check 0.1.5", +] + +[[package]] +name = "num-traits" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c81ffc11c212fa327657cb19dd85eb7419e163b5b076bede2bdb5c974c07e4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "paste" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "423a519e1c6e828f1e73b720f9d9ed2fa643dce8a7737fb43235ce0b41eeaa49" +dependencies = [ + "paste-impl", + "proc-macro-hack", +] + +[[package]] +name = "paste-impl" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4214c9e912ef61bf42b81ba9a47e8aad1b2ffaf739ab162bf96d1e011f54e6c5" +dependencies = [ + "proc-macro-hack", + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", +] + +[[package]] +name = "petgraph" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3659d1ee90221741f65dd128d9998311b0e40c5d3c23a62445938214abce4f" +dependencies = [ + "fixedbitset", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74490b50b9fbe561ac330df47c08f3f33073d2d00c150f719147d7c54522fa1b" + +[[package]] +name = "proc-macro-hack" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecd45702f76d6d3c75a80564378ae228a85f0b59d2f3ed43c91b4a69eb2ebfc5" +dependencies = [ + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", +] + +[[package]] +name = "proc-macro2" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759" +dependencies = [ + "unicode-xid 0.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e470a8dc4aeae2dee2f335e8f533e2d4b347e1434e5671afc49b054592f27" +dependencies = [ + "unicode-xid 0.2.0", +] + +[[package]] +name = "prost" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96d14b1c185652833d24aaad41c5832b0be5616a590227c1fbff57c616754b23" +dependencies = [ + "byteorder", + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb788126ea840817128183f8f603dce02cb7aea25c2a0b764359d8e20010702e" +dependencies = [ + "bytes", + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prost", + "prost-types", + "tempfile", + "which", +] + +[[package]] +name = "prost-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7dc378b94ac374644181a2247cebf59a6ec1c88b49ac77f3a94b86b79d0e11" +dependencies = [ + "failure", + "itertools", + "proc-macro2 0.4.30", + "quote 0.6.13", + "syn 0.15.44", +] + +[[package]] +name = "prost-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de482a366941c8d56d19b650fac09ca08508f2a696119ee7513ad590c8bac6f" +dependencies = [ + "bytes", + "prost", +] + +[[package]] +name = "pymod" +version = "0.1.0" +dependencies = [ + "ankirs", + "pyo3", +] + +[[package]] +name = "pyo3" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9df1468dddf8a59ec799cf3b930bb75ec09deabe875ba953e06c51d1077136" +dependencies = [ + "indoc", + "inventory", + "lazy_static", + "libc", + "num-traits", + "paste", + "pyo3cls", + "regex", + "serde", + "serde_json", + "spin", + "unindent", + "version_check 0.9.1", +] + +[[package]] +name = "pyo3-derive-backend" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f6e56fb3e97b344a8f87d036f94578399402c6b75949de6270cd07928f790b1" +dependencies = [ + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", +] + +[[package]] +name = "pyo3cls" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97452dcdf5941627ebc5c06664a07821fc7fc88d7515f02178193a8ebe316468" +dependencies = [ + "proc-macro2 1.0.6", + "pyo3-derive-backend", + "quote 1.0.2", + "syn 1.0.11", +] + +[[package]] +name = "quote" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1" +dependencies = [ + "proc-macro2 0.4.30", +] + +[[package]] +name = "quote" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053a8c8bcc71fcce321828dc897a98ab9760bef03a4fc36693c231e5b3216cfe" +dependencies = [ + "proc-macro2 1.0.6", +] + +[[package]] +name = "rand" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae1b169243eaf61759b8475a998f0a385e42042370f3a7dbaf35246eacc8412" +dependencies = [ + "getrandom", + "libc", + "rand_chacha", + "rand_core", + "rand_hc", +] + +[[package]] +name = "rand_chacha" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03a2a90da8c7523f554344f921aa97283eadf6ac484a6d2a7d0212fa7f8d6853" +dependencies = [ + "c2-chacha", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.1.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2439c63f3f6139d1b57529d16bc3b8bb855230c8efcc5d3a896c8bea7c3b1e84" + +[[package]] +name = "regex" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc220bd33bdce8f093101afe22a037b8eb0e5af33592e6a9caafff0d4cb81cbd" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", + "thread_local", +] + +[[package]] +name = "regex-syntax" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11a7e20d1cce64ef2fed88b66d347f88bd9babb82845b2b858f3edbf59a4f716" + +[[package]] +name = "remove_dir_all" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a83fa3702a688b9359eccba92d153ac33fd2e8462f9e0e3fdf155239ea7792e" +dependencies = [ + "winapi", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c691c0e608126e00913e33f0ccf3727d5fc84573623b8d65b2df340b5201783" + +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver", +] + +[[package]] +name = "ryu" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa8506c1de11c9c4e4c38863ccbe02a305c8188e85a05a784c9e11e1c3910c8" + +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + +[[package]] +name = "serde" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "414115f25f818d7dfccec8ee535d76949ae78584fc4f79a6f45a904bf8ab4449" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128f9e303a5a29922045a830221b8f78ec74a5f544944f3d5984f8ec3895ef64" +dependencies = [ + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", +] + +[[package]] +name = "serde_json" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c575e0cc52bdd09b47f330f646cf59afc586e9c4e3ccd6fc1f625b8ea1dad7" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "static_assertions" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f3eb36b47e512f8f1c9e3d10c2c1965bc992bd9cdb024fa581e2194501c83d3" + +[[package]] +name = "syn" +version = "0.15.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ca4b3b69a77cbe1ffc9e198781b7acb0c7365a883670e8f1c1bc66fba79a5c5" +dependencies = [ + "proc-macro2 0.4.30", + "quote 0.6.13", + "unicode-xid 0.1.0", +] + +[[package]] +name = "syn" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff0acdb207ae2fe6d5976617f887eb1e35a2ba52c13c7234c790960cdad9238" +dependencies = [ + "proc-macro2 1.0.6", + "quote 1.0.2", + "unicode-xid 0.2.0", +] + +[[package]] +name = "synstructure" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67656ea1dc1b41b1451851562ea232ec2e5a80242139f7e679ceccfb5d61f545" +dependencies = [ + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", + "unicode-xid 0.2.0", +] + +[[package]] +name = "tempfile" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6e24d9338a0a5be79593e2fa15a648add6138caa803e2d5bc782c371732ca9" +dependencies = [ + "cfg-if", + "libc", + "rand", + "redox_syscall", + "remove_dir_all", + "winapi", +] + +[[package]] +name = "thread_local" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6b53e329000edc2b34dbe8545fd20e55a333362d0a321909685a19bd28c3f1b" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "unicode-segmentation" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83e153d1053cbb5a118eeff7fd5be06ed99153f00dbcd8ae310c5fb2b22edc0" + +[[package]] +name = "unicode-xid" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc" + +[[package]] +name = "unicode-xid" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826e7639553986605ec5979c7dd957c7895e93eabed50ab2ffa7f6128a75097c" + +[[package]] +name = "unindent" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63f18aa3b0e35fed5a0048f029558b1518095ffe2a0a31fb87c93dece93a4993" + +[[package]] +name = "version_check" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914b1a6776c4c929a602fafd8bc742e06365d4bcbe48c30f9cca5824f70dc9dd" + +[[package]] +name = "version_check" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078775d0255232fb988e6fccf26ddc9d1ac274299aaedcedce21c6f72cc533ce" + +[[package]] +name = "wasi" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c3ce4ce14bdc6fb6beaf9ec7928ca331de5df7e5ea278375642a2f478570d" + +[[package]] +name = "which" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b57acb10231b9493c8472b20cb57317d0679a49e0bdbee44b3b803a6473af164" +dependencies = [ + "failure", + "libc", +] + +[[package]] +name = "winapi" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/rs/Cargo.toml b/rs/Cargo.toml new file mode 100644 index 000000000..84ba7cfc3 --- /dev/null +++ b/rs/Cargo.toml @@ -0,0 +1,6 @@ +[workspace] +members = ["ankirs", "pymod"] + +[profile.release] +lto = true +codegen-units = 1 diff --git a/rs/ankirs/Cargo.toml b/rs/ankirs/Cargo.toml new file mode 100644 index 000000000..eb11872f3 --- /dev/null +++ b/rs/ankirs/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ankirs" +version = "0.1.0" +edition = "2018" +authors = ["Ankitects Pty Ltd and contributors"] + +[dependencies] +nom = "5.0.1" +failure = "0.1.6" +prost = "0.5.0" +bytes = "0.4" + +[build-dependencies] +prost-build = "0.5.0" diff --git a/rs/ankirs/build.rs b/rs/ankirs/build.rs new file mode 100644 index 000000000..88ef4f590 --- /dev/null +++ b/rs/ankirs/build.rs @@ -0,0 +1,7 @@ +use prost_build; + +fn main() { + // avoid default OUT_DIR for now, for code completion + std::env::set_var("OUT_DIR", "src"); + prost_build::compile_protos(&["../../proto/backend.proto"], &["../../proto/"]).unwrap(); +} diff --git a/rs/ankirs/src/backend.rs b/rs/ankirs/src/backend.rs new file mode 100644 index 000000000..3a730126b --- /dev/null +++ b/rs/ankirs/src/backend.rs @@ -0,0 +1,139 @@ +use crate::backend_proto as pt; +use crate::backend_proto::backend_input::Value; +use crate::err::{AnkiError, Result}; +use crate::template::{FieldMap, FieldRequirements, ParsedTemplate}; +use prost::Message; +use std::collections::HashSet; + +pub struct Backend {} + +impl Default for Backend { + fn default() -> Self { + Backend {} + } +} + +/// Convert an Anki error to a protobuf error. +impl std::convert::From for pt::BackendError { + fn from(err: AnkiError) -> Self { + use pt::backend_error::Value as V; + let value = match err { + AnkiError::InvalidInput { info } => V::InvalidInput(pt::InvalidInputError { info }), + AnkiError::TemplateParseError { info } => { + V::TemplateParse(pt::TemplateParseError { info }) + } + }; + + pt::BackendError { value: Some(value) } + } +} + +// Convert an Anki error to a protobuf output. +impl std::convert::From for pt::backend_output::Value { + fn from(err: AnkiError) -> Self { + pt::backend_output::Value::Error(err.into()) + } +} + +impl Backend { + pub fn new() -> Backend { + Backend::default() + } + + /// Decode a request, process it, and return the encoded result. + pub fn run_command_bytes(&mut self, req: &[u8]) -> Vec { + let mut buf = vec![]; + + let req = match pt::BackendInput::decode(req) { + Ok(req) => req, + Err(_e) => { + // unable to decode + let err = AnkiError::invalid_input("couldn't decode backend request"); + let output = pt::BackendOutput { + value: Some(err.into()), + }; + output.encode(&mut buf).expect("encode failed"); + return buf; + } + }; + + let resp = self.run_command(req); + resp.encode(&mut buf).expect("encode failed"); + buf + } + + fn run_command(&self, input: pt::BackendInput) -> pt::BackendOutput { + let oval = if let Some(ival) = input.value { + match self.run_command_inner(ival) { + Ok(output) => output, + Err(err) => err.into(), + } + } else { + AnkiError::invalid_input("unrecognized backend input value").into() + }; + + pt::BackendOutput { value: Some(oval) } + } + + fn run_command_inner( + &self, + ival: pt::backend_input::Value, + ) -> Result { + use pt::backend_output::Value as OValue; + Ok(match ival { + Value::TemplateRequirements(input) => { + OValue::TemplateRequirements(self.template_requirements(input)?) + } + Value::PlusOne(input) => OValue::PlusOne(self.plus_one(input)?), + }) + } + + fn plus_one(&self, input: pt::PlusOneIn) -> Result { + let num = input.num + 1; + Ok(pt::PlusOneOut { num }) + } + + fn template_requirements( + &self, + input: pt::TemplateRequirementsIn, + ) -> Result { + let map: FieldMap = input + .field_names_to_ordinals + .iter() + .map(|(name, ord)| (name.as_str(), *ord as u16)) + .collect(); + // map each provided template into a requirements list + use crate::backend_proto::template_requirement::Value; + let all_reqs = input + .template_front + .into_iter() + .map(|template| { + if let Ok(tmpl) = ParsedTemplate::from_text(&template) { + // convert the rust structure into a protobuf one + let val = match tmpl.requirements(&map) { + FieldRequirements::Any(ords) => Value::Any(pt::TemplateRequirementAny { + ords: ords_hash_to_set(ords), + }), + FieldRequirements::All(ords) => Value::All(pt::TemplateRequirementAll { + ords: ords_hash_to_set(ords), + }), + FieldRequirements::None => Value::None(pt::Empty {}), + }; + Ok(pt::TemplateRequirement { value: Some(val) }) + } else { + // template parsing failures make card unsatisfiable + Ok(pt::TemplateRequirement { + value: Some(Value::None(pt::Empty {})), + }) + } + }) + .collect::>>()?; + Ok(pt::TemplateRequirementsOut { + requirements: all_reqs, + }) + } +} + +fn ords_hash_to_set(ords: HashSet) -> Vec { + ords.iter().map(|ord| *ord as u32).collect() +} diff --git a/rs/ankirs/src/err.rs b/rs/ankirs/src/err.rs new file mode 100644 index 000000000..acf3a2062 --- /dev/null +++ b/rs/ankirs/src/err.rs @@ -0,0 +1,23 @@ +pub use failure::{Error, Fail}; + +pub type Result = std::result::Result; + +#[derive(Debug, Fail)] +pub enum AnkiError { + #[fail(display = "invalid input: {}", info)] + InvalidInput { info: String }, + + #[fail(display = "invalid card template: {}", info)] + TemplateParseError { info: String }, +} + +// error helpers +impl AnkiError { + pub(crate) fn parse>(s: S) -> AnkiError { + AnkiError::TemplateParseError { info: s.into() } + } + + pub(crate) fn invalid_input>(s: S) -> AnkiError { + AnkiError::InvalidInput { info: s.into() } + } +} diff --git a/rs/ankirs/src/lib.rs b/rs/ankirs/src/lib.rs new file mode 100644 index 000000000..c42efa231 --- /dev/null +++ b/rs/ankirs/src/lib.rs @@ -0,0 +1,5 @@ +mod backend_proto; + +pub mod backend; +pub mod err; +pub mod template; diff --git a/rs/ankirs/src/template.rs b/rs/ankirs/src/template.rs new file mode 100644 index 000000000..ab1758f74 --- /dev/null +++ b/rs/ankirs/src/template.rs @@ -0,0 +1,360 @@ +use crate::err::{AnkiError, Result}; +use nom; +use nom::branch::alt; +use nom::bytes::complete::tag; +use nom::error::ErrorKind; +use nom::sequence::delimited; +use std::collections::{HashMap, HashSet}; + +pub type FieldMap<'a> = HashMap<&'a str, u16>; + +// Lexing +//---------------------------------------- + +#[derive(Debug)] +pub enum Token<'a> { + Text(&'a str), + Replacement(&'a str), + OpenConditional(&'a str), + OpenNegated(&'a str), + CloseConditional(&'a str), +} + +/// a span of text, terminated by {{, }} or end of string +pub(crate) fn text_until_handlebars(s: &str) -> nom::IResult<&str, &str> { + let end = s.len(); + + let limited_end = end + .min(s.find("{{").unwrap_or(end)) + .min(s.find("}}").unwrap_or(end)); + let (output, input) = s.split_at(limited_end); + if output.is_empty() { + Err(nom::Err::Error((input, ErrorKind::TakeUntil))) + } else { + Ok((input, output)) + } +} + +/// text outside handlebars +fn text_token(s: &str) -> nom::IResult<&str, Token> { + text_until_handlebars(s).map(|(input, output)| (input, Token::Text(output))) +} + +/// text wrapped in handlebars +fn handle_token(s: &str) -> nom::IResult<&str, Token> { + delimited(tag("{{"), text_until_handlebars, tag("}}"))(s) + .map(|(input, output)| (input, classify_handle(output))) +} + +/// classify handle based on leading character +fn classify_handle(s: &str) -> Token { + let start = s.trim(); + if start.len() < 2 { + return Token::Replacement(start); + } + if start.starts_with('#') { + Token::OpenConditional(&start[1..].trim_start()) + } else if start.starts_with('/') { + Token::CloseConditional(&start[1..].trim_start()) + } else if start.starts_with('^') { + Token::OpenNegated(&start[1..].trim_start()) + } else { + Token::Replacement(start) + } +} + +fn next_token(input: &str) -> nom::IResult<&str, Token> { + alt((handle_token, text_token))(input) +} + +fn tokens(template: &str) -> impl Iterator> { + let mut data = template; + + std::iter::from_fn(move || { + if data.is_empty() { + return None; + } + match next_token(data) { + Ok((i, o)) => { + data = i; + Some(Ok(o)) + } + Err(e) => Some(Err(AnkiError::parse(format!("{:?}", e)))), + } + }) +} + +// Parsing +//---------------------------------------- + +#[derive(Debug, PartialEq)] +enum ParsedNode<'a> { + Text(&'a str), + Replacement { + key: &'a str, + filters: Vec<&'a str>, + }, + Conditional { + key: &'a str, + children: Vec>, + }, + NegatedConditional { + key: &'a str, + children: Vec>, + }, +} + +#[derive(Debug)] +pub struct ParsedTemplate<'a>(Vec>); + +impl ParsedTemplate<'_> { + pub fn from_text(template: &str) -> Result { + let mut iter = tokens(template); + Ok(Self(parse_inner(&mut iter, None)?)) + } +} + +fn parse_inner<'a, I: Iterator>>>( + iter: &mut I, + open_tag: Option<&'a str>, +) -> Result>> { + let mut nodes = vec![]; + + while let Some(token) = iter.next() { + use Token::*; + nodes.push(match token? { + Text(t) => ParsedNode::Text(t), + Replacement(t) => { + let mut it = t.rsplit(':'); + ParsedNode::Replacement { + key: it.next().unwrap(), + filters: it.collect(), + } + } + OpenConditional(t) => ParsedNode::Conditional { + key: t, + children: parse_inner(iter, Some(t))?, + }, + OpenNegated(t) => ParsedNode::NegatedConditional { + key: t, + children: parse_inner(iter, Some(t))?, + }, + CloseConditional(t) => { + if let Some(open) = open_tag { + if open == t { + // matching closing tag, move back to parent + return Ok(nodes); + } + } + return Err(AnkiError::parse(format!( + "unbalanced closing tag: {:?} / {}", + open_tag, t + ))); + } + }); + } + + if let Some(open) = open_tag { + Err(AnkiError::parse(format!("unclosed conditional {}", open))) + } else { + Ok(nodes) + } +} + +// Checking if template is empty +//---------------------------------------- + +impl ParsedTemplate<'_> { + /// true if provided fields are sufficient to render the template + pub fn renders_with_fields(&self, nonempty_fields: &HashSet<&str>) -> bool { + !template_is_empty(nonempty_fields, &self.0) + } +} + +fn template_is_empty<'a>(nonempty_fields: &HashSet<&str>, nodes: &[ParsedNode<'a>]) -> bool { + use ParsedNode::*; + for node in nodes { + match node { + // ignore normal text + Text(_) => (), + Replacement { key, filters } => { + // Anki doesn't consider a type: reference as a required field + if filters.contains(&"type") { + continue; + } + + if nonempty_fields.contains(*key) { + // a single replacement is enough + return false; + } + } + Conditional { key, children } => { + if !nonempty_fields.contains(*key) { + continue; + } + if !template_is_empty(nonempty_fields, children) { + return false; + } + } + NegatedConditional { .. } => { + // negated conditionals ignored when determining card generation + continue; + } + } + } + + true +} + +// Compatibility with old Anki versions +//---------------------------------------- + +#[derive(Debug, Clone, PartialEq)] +pub enum FieldRequirements { + Any(HashSet), + All(HashSet), + None, +} + +impl ParsedTemplate<'_> { + /// Return fields required by template. + /// + /// This is not able to represent negated expressions or combinations of + /// Any and All, and is provided only for the sake of backwards + /// compatibility. + pub fn requirements(&self, field_map: &FieldMap) -> FieldRequirements { + let mut nonempty: HashSet<_> = Default::default(); + let mut ords = HashSet::new(); + for (name, ord) in field_map { + nonempty.clear(); + nonempty.insert(*name); + if self.renders_with_fields(&nonempty) { + ords.insert(*ord); + } + } + if !ords.is_empty() { + return FieldRequirements::Any(ords); + } + + nonempty.extend(field_map.keys()); + ords.extend(field_map.values().copied()); + for (name, ord) in field_map { + // can we remove this field and still render? + nonempty.remove(name); + if self.renders_with_fields(&nonempty) { + ords.remove(ord); + } + nonempty.insert(*name); + } + if !ords.is_empty() && self.renders_with_fields(&nonempty) { + FieldRequirements::All(ords) + } else { + FieldRequirements::None + } + } +} + +// Tests +//--------------------------------------- + +#[cfg(test)] +mod test { + use super::{FieldMap, ParsedNode::*, ParsedTemplate as PT}; + use crate::template::FieldRequirements; + use std::collections::HashSet; + use std::iter::FromIterator; + + #[test] + fn test_parsing() { + let tmpl = PT::from_text("foo {{bar}} {{#baz}} quux {{/baz}}").unwrap(); + assert_eq!( + tmpl.0, + vec![ + Text("foo "), + Replacement { + key: "bar", + filters: vec![] + }, + Text(" "), + Conditional { + key: "baz", + children: vec![Text(" quux ")] + } + ] + ); + + let tmpl = PT::from_text("{{^baz}}{{/baz}}").unwrap(); + assert_eq!( + tmpl.0, + vec![NegatedConditional { + key: "baz", + children: vec![] + }] + ); + + PT::from_text("{{#mis}}{{/matched}}").unwrap_err(); + PT::from_text("{{/matched}}").unwrap_err(); + PT::from_text("{{#mis}}").unwrap_err(); + + // whitespace + assert_eq!( + PT::from_text("{{ tag }}").unwrap().0, + vec![Replacement { + key: "tag", + filters: vec![] + }] + ); + } + + #[test] + fn test_nonempty() { + let fields = HashSet::from_iter(vec!["1", "3"].into_iter()); + let mut tmpl = PT::from_text("{{2}}{{1}}").unwrap(); + assert_eq!(tmpl.renders_with_fields(&fields), true); + tmpl = PT::from_text("{{2}}{{type:cloze:1}}").unwrap(); + assert_eq!(tmpl.renders_with_fields(&fields), false); + tmpl = PT::from_text("{{2}}{{4}}").unwrap(); + assert_eq!(tmpl.renders_with_fields(&fields), false); + tmpl = PT::from_text("{{#3}}{{^2}}{{1}}{{/2}}{{/3}}").unwrap(); + assert_eq!(tmpl.renders_with_fields(&fields), false); + } + + #[test] + fn test_requirements() { + let field_map: FieldMap = vec!["a", "b"] + .iter() + .enumerate() + .map(|(a, b)| (*b, a as u16)) + .collect(); + + let mut tmpl = PT::from_text("{{a}}{{b}}").unwrap(); + assert_eq!( + tmpl.requirements(&field_map), + FieldRequirements::Any(HashSet::from_iter(vec![0, 1].into_iter())) + ); + + tmpl = PT::from_text("{{#a}}{{b}}{{/a}}").unwrap(); + assert_eq!( + tmpl.requirements(&field_map), + FieldRequirements::All(HashSet::from_iter(vec![0, 1].into_iter())) + ); + + tmpl = PT::from_text("{{c}}").unwrap(); + assert_eq!(tmpl.requirements(&field_map), FieldRequirements::None); + + tmpl = PT::from_text("{{^a}}{{b}}{{/a}}").unwrap(); + assert_eq!(tmpl.requirements(&field_map), FieldRequirements::None); + + tmpl = PT::from_text("{{#a}}{{#b}}{{a}}{{/b}}{{/a}}").unwrap(); + assert_eq!( + tmpl.requirements(&field_map), + FieldRequirements::All(HashSet::from_iter(vec![0, 1].into_iter())) + ); + + tmpl = PT::from_text("{{a}}{{type:b}}").unwrap(); + assert_eq!( + tmpl.requirements(&field_map), + FieldRequirements::Any(HashSet::from_iter(vec![0].into_iter())) + ); + } +} diff --git a/rs/pymod/Cargo.toml b/rs/pymod/Cargo.toml new file mode 100644 index 000000000..b09c3399a --- /dev/null +++ b/rs/pymod/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "pymod" +version = "0.1.0" +edition = "2018" +authors = ["Ankitects Pty Ltd and contributors"] + +[dependencies] +ankirs = { path = "../ankirs" } + +[dependencies.pyo3] +version = "0.8.0" +features = ["extension-module"] + +[lib] +name = "_ankirs" +crate-type = ["cdylib"] diff --git a/rs/pymod/src/lib.rs b/rs/pymod/src/lib.rs new file mode 100644 index 000000000..dbb18c339 --- /dev/null +++ b/rs/pymod/src/lib.rs @@ -0,0 +1,33 @@ +use ankirs::backend::Backend as RustBackend; +use pyo3::prelude::*; +use pyo3::types::PyBytes; + +#[pyclass] +struct Backend { + backend: RustBackend, +} + +#[pymethods] +impl Backend { + #[new] + fn init(obj: &PyRawObject) { + obj.init({ + Backend { + backend: Default::default(), + } + }); + } + + fn command(&mut self, py: Python, input: &PyBytes) -> PyResult { + let out_bytes = self.backend.run_command_bytes(input.as_bytes()); + let out_obj = PyBytes::new(py, &out_bytes); + Ok(out_obj.into()) + } +} + +#[pymodule] +fn _ankirs(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_class::()?; + + Ok(()) +} diff --git a/rs/rust-toolchain b/rs/rust-toolchain new file mode 100644 index 000000000..a9fe5202c --- /dev/null +++ b/rs/rust-toolchain @@ -0,0 +1 @@ +nightly-2019-12-15 diff --git a/rs/rustfmt.toml b/rs/rustfmt.toml new file mode 100644 index 000000000..bd0ab67a8 --- /dev/null +++ b/rs/rustfmt.toml @@ -0,0 +1 @@ +ignore = ["backend_proto.rs"] diff --git a/tests/shared.py b/tests/shared.py index 70acfe4c0..5294a84f0 100644 --- a/tests/shared.py +++ b/tests/shared.py @@ -1,6 +1,10 @@ -import tempfile, os, shutil +import os +import shutil +import tempfile + from anki import Collection as aopen + def assertException(exception, func): found = False try: @@ -25,6 +29,7 @@ def getEmptyCol(): col = aopen(nam) return col + getEmptyCol.master = "" # Fallback for when the DB needs options passed in. @@ -34,10 +39,12 @@ def getEmptyDeckWith(**kwargs): os.unlink(nam) return aopen(nam, **kwargs) + def getUpgradeDeckPath(name="anki12.anki"): src = os.path.join(testDir, "support", name) (fd, dst) = tempfile.mkstemp(suffix=".anki2") shutil.copy(src, dst) return dst + testDir = os.path.dirname(__file__) diff --git a/tests/test_addons.py b/tests/test_addons.py index f42bc2c5d..c5708842c 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -1,73 +1,57 @@ import os.path -from nose.tools import assert_equals -from mock import MagicMock from tempfile import TemporaryDirectory from zipfile import ZipFile +from mock import MagicMock +from nose2.tools.such import helper + from aqt.addons import AddonManager def test_readMinimalManifest(): assertReadManifest( - '{"package": "yes", "name": "no"}', - {"package": "yes", "name": "no"} + '{"package": "yes", "name": "no"}', {"package": "yes", "name": "no"} ) def test_readExtraKeys(): assertReadManifest( '{"package": "a", "name": "b", "mod": 3, "conflicts": ["d", "e"]}', - {"package": "a", "name": "b", "mod": 3, "conflicts": ["d", "e"]} + {"package": "a", "name": "b", "mod": 3, "conflicts": ["d", "e"]}, ) def test_invalidManifest(): - assertReadManifest( - '{"one": 1}', - {} - ) + assertReadManifest('{"one": 1}', {}) def test_mustHaveName(): - assertReadManifest( - '{"package": "something"}', - {} - ) + assertReadManifest('{"package": "something"}', {}) def test_mustHavePackage(): - assertReadManifest( - '{"name": "something"}', - {} - ) + assertReadManifest('{"name": "something"}', {}) def test_invalidJson(): - assertReadManifest( - 'this is not a JSON dictionary', - {} - ) + assertReadManifest("this is not a JSON dictionary", {}) def test_missingManifest(): assertReadManifest( - '{"package": "what", "name": "ever"}', - {}, - nameInZip="not-manifest.bin" + '{"package": "what", "name": "ever"}', {}, nameInZip="not-manifest.bin" ) def test_ignoreExtraKeys(): assertReadManifest( - '{"package": "a", "name": "b", "game": "c"}', - {"package": "a", "name": "b"} + '{"package": "a", "name": "b", "game": "c"}', {"package": "a", "name": "b"} ) def test_conflictsMustBeStrings(): assertReadManifest( - '{"package": "a", "name": "b", "conflicts": ["c", 4, {"d": "e"}]}', - {} + '{"package": "a", "name": "b", "conflicts": ["c", 4, {"d": "e"}]}', {} ) @@ -80,4 +64,4 @@ def assertReadManifest(contents, expectedManifest, nameInZip="manifest.json"): adm = AddonManager(MagicMock()) with ZipFile(zfn, "r") as zfile: - assert_equals(adm.readManifestFile(zfile), expectedManifest) + helper.assertEquals(adm.readManifestFile(zfile), expectedManifest) diff --git a/tests/test_cards.py b/tests/test_cards.py index b0732b80b..6e519c84f 100644 --- a/tests/test_cards.py +++ b/tests/test_cards.py @@ -2,11 +2,12 @@ from tests.shared import getEmptyCol + def test_previewCards(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = '1' - f['Back'] = '2' + f["Front"] = "1" + f["Back"] = "2" # non-empty and active cards = deck.previewCards(f, 0) assert len(cards) == 1 @@ -22,11 +23,12 @@ def test_previewCards(): # make sure we haven't accidentally added cards to the db assert deck.cardCount() == 1 + def test_delete(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = '1' - f['Back'] = '2' + f["Front"] = "1" + f["Back"] = "2" deck.addNote(f) cid = f.cards()[0].id deck.reset() @@ -38,62 +40,65 @@ def test_delete(): assert deck.db.scalar("select count() from cards") == 0 assert deck.db.scalar("select count() from graves") == 2 + def test_misc(): d = getEmptyCol() f = d.newNote() - f['Front'] = '1' - f['Back'] = '2' + f["Front"] = "1" + f["Back"] = "2" d.addNote(f) c = f.cards()[0] - id = d.models.current()['id'] - assert c.template()['ord'] == 0 + id = d.models.current()["id"] + assert c.template()["ord"] == 0 + def test_genrem(): d = getEmptyCol() f = d.newNote() - f['Front'] = '1' - f['Back'] = '' + f["Front"] = "1" + f["Back"] = "" d.addNote(f) assert len(f.cards()) == 1 m = d.models.current() mm = d.models # adding a new template should automatically create cards t = mm.newTemplate("rev") - t['qfmt'] = '{{Front}}' - t['afmt'] = "" + t["qfmt"] = "{{Front}}" + t["afmt"] = "" mm.addTemplate(m, t) mm.save(m, templates=True) assert len(f.cards()) == 2 # if the template is changed to remove cards, they'll be removed - t['qfmt'] = "{{Back}}" + t["qfmt"] = "{{Back}}" mm.save(m, templates=True) d.remCards(d.emptyCids()) assert len(f.cards()) == 1 # if we add to the note, a card should be automatically generated f.load() - f['Back'] = "1" + f["Back"] = "1" f.flush() assert len(f.cards()) == 2 + def test_gendeck(): d = getEmptyCol() cloze = d.models.byName("Cloze") d.models.setCurrent(cloze) f = d.newNote() - f['Text'] = '{{c1::one}}' + f["Text"] = "{{c1::one}}" d.addNote(f) assert d.cardCount() == 1 assert f.cards()[0].did == 1 # set the model to a new default deck newId = d.decks.id("new") - cloze['did'] = newId + cloze["did"] = newId d.models.save(cloze, updateReqs=False) # a newly generated card should share the first card's deck - f['Text'] += '{{c2::two}}' + f["Text"] += "{{c2::two}}" f.flush() assert f.cards()[1].did == 1 # and same with multiple cards - f['Text'] += '{{c3::three}}' + f["Text"] += "{{c3::three}}" f.flush() assert f.cards()[2].did == 1 # if one of the cards is in a different deck, it should revert to the @@ -101,9 +106,6 @@ def test_gendeck(): c = f.cards()[1] c.did = newId c.flush() - f['Text'] += '{{c4::four}}' + f["Text"] += "{{c4::four}}" f.flush() assert f.cards()[3].did == newId - - - diff --git a/tests/test_collection.py b/tests/test_collection.py index 7aed8d127..43ce5d058 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -1,16 +1,15 @@ # coding: utf-8 -import os, tempfile -from tests.shared import assertException, getEmptyCol -from anki.stdmodels import addBasicModel, models +import os +import tempfile from anki import Collection as aopen +from anki.stdmodels import addBasicModel, models +from anki.utils import isWin +from tests.shared import assertException, getEmptyCol -newPath = None -newMod = None def test_create_open(): - global newPath, newMod (fd, path) = tempfile.mkstemp(suffix=".anki2", prefix="test_attachNew") try: os.close(fd) @@ -30,27 +29,32 @@ def test_create_open(): deck.close() # non-writeable dir - assertException(Exception, - lambda: aopen("/attachroot.anki2")) + if isWin: + dir = "c:\root.anki2" + else: + dir = "/attachroot.anki2" + assertException(Exception, lambda: aopen(dir)) # reuse tmp file from before, test non-writeable file os.chmod(newPath, 0) - assertException(Exception, - lambda: aopen(newPath)) + assertException(Exception, lambda: aopen(newPath)) os.chmod(newPath, 0o666) os.unlink(newPath) + def test_noteAddDelete(): deck = getEmptyCol() # add a note f = deck.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" n = deck.addNote(f) assert n == 1 # test multiple cards - add another template - m = deck.models.current(); mm = deck.models + m = deck.models.current() + mm = deck.models t = mm.newTemplate("Reverse") - t['qfmt'] = "{{Back}}" - t['afmt'] = "{{Front}}" + t["qfmt"] = "{{Back}}" + t["afmt"] = "{{Front}}" mm.addTemplate(m, t) mm.save(m) # the default save doesn't generate cards @@ -61,7 +65,8 @@ def test_noteAddDelete(): assert deck.cardCount() == 2 # creating new notes should use both cards f = deck.newNote() - f['Front'] = "three"; f['Back'] = "four" + f["Front"] = "three" + f["Back"] = "four" n = deck.addNote(f) assert n == 2 assert deck.cardCount() == 4 @@ -72,36 +77,39 @@ def test_noteAddDelete(): assert not f.dupeOrEmpty() # now let's make a duplicate f2 = deck.newNote() - f2['Front'] = "one"; f2['Back'] = "" + f2["Front"] = "one" + f2["Back"] = "" assert f2.dupeOrEmpty() # empty first field should not be permitted either - f2['Front'] = " " + f2["Front"] = " " assert f2.dupeOrEmpty() + def test_fieldChecksum(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = "new"; f['Back'] = "new2" + f["Front"] = "new" + f["Back"] = "new2" deck.addNote(f) - assert deck.db.scalar( - "select csum from notes") == int("c2a6b03f", 16) + assert deck.db.scalar("select csum from notes") == int("c2a6b03f", 16) # changing the val should change the checksum - f['Front'] = "newx" + f["Front"] = "newx" f.flush() - assert deck.db.scalar( - "select csum from notes") == int("302811ae", 16) + assert deck.db.scalar("select csum from notes") == int("302811ae", 16) + def test_addDelTags(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = "1" + f["Front"] = "1" deck.addNote(f) f2 = deck.newNote() - f2['Front'] = "2" + f2["Front"] = "2" deck.addNote(f2) # adding for a given id deck.tags.bulkAdd([f.id], "foo") - f.load(); f2.load() + f.load() + f2.load() assert "foo" in f.tags assert "foo" not in f2.tags # should be canonified @@ -110,6 +118,7 @@ def test_addDelTags(): assert f.tags[0] == "aaa" assert len(f.tags) == 2 + def test_timestamps(): deck = getEmptyCol() assert len(deck.models.models) == len(models) @@ -117,23 +126,24 @@ def test_timestamps(): addBasicModel(deck) assert len(deck.models.models) == 100 + len(models) + def test_furigana(): deck = getEmptyCol() mm = deck.models m = mm.current() # filter should work - m['tmpls'][0]['qfmt'] = '{{kana:Front}}' + m["tmpls"][0]["qfmt"] = "{{kana:Front}}" mm.save(m) n = deck.newNote() - n['Front'] = 'foo[abc]' + n["Front"] = "foo[abc]" deck.addNote(n) c = n.cards()[0] assert c.q().endswith("abc") # and should avoid sound - n['Front'] = 'foo[sound:abc.mp3]' + n["Front"] = "foo[sound:abc.mp3]" n.flush() assert "sound:" in c.q(reload=True) # it shouldn't throw an error while people are editing - m['tmpls'][0]['qfmt'] = '{{kana:}}' + m["tmpls"][0]["qfmt"] = "{{kana:}}" mm.save(m) c.q(reload=True) diff --git a/tests/test_decks.py b/tests/test_decks.py index 86aa8f50c..b53510d27 100644 --- a/tests/test_decks.py +++ b/tests/test_decks.py @@ -3,6 +3,7 @@ from anki.errors import DeckRenameError from tests.shared import assertException, getEmptyCol + def test_basic(): deck = getEmptyCol() # we start with a standard deck @@ -34,21 +35,22 @@ def test_basic(): # parents with a different case should be handled correctly deck.decks.id("ONE") m = deck.models.current() - m['did'] = deck.decks.id("one::two") + m["did"] = deck.decks.id("one::two") deck.models.save(m, updateReqs=False) n = deck.newNote() - n['Front'] = "abc" + n["Front"] = "abc" deck.addNote(n) # this will error if child and parent case don't match deck.sched.deckDueList() + def test_remove(): deck = getEmptyCol() # create a new deck, and add a note/card to it g1 = deck.decks.id("g1") f = deck.newNote() - f['Front'] = "1" - f.model()['did'] = g1 + f["Front"] = "1" + f.model()["did"] = g1 deck.addNote(f) c = f.cards()[0] assert c.did == g1 @@ -62,12 +64,14 @@ def test_remove(): assert deck.decks.name(c.did) == "[no deck]" # let's create another deck and explicitly set the card to it g2 = deck.decks.id("g2") - c.did = g2; c.flush() + c.did = g2 + c.flush() # this time we'll delete the card/note too deck.decks.rem(g2, cardsToo=True) assert deck.cardCount() == 0 assert deck.noteCount() == 0 + def test_rename(): d = getEmptyCol() id = d.decks.id("hello::world") @@ -80,8 +84,7 @@ def test_rename(): # create another deck id = d.decks.id("tmp") # we can't rename it if it conflicts - assertException( - Exception, lambda: d.decks.rename(d.decks.get(id), "foo")) + assertException(Exception, lambda: d.decks.rename(d.decks.get(id), "foo")) # when renaming, the children should be renamed too d.decks.id("one::two::three") id = d.decks.id("one") @@ -102,62 +105,66 @@ def test_rename(): assertException(DeckRenameError, lambda: d.decks.rename(child, "PARENT::child")) - def test_renameForDragAndDrop(): d = getEmptyCol() def deckNames(): - return [ name for name in sorted(d.decks.allNames()) if name != 'Default' ] + return [name for name in sorted(d.decks.allNames()) if name != "Default"] - languages_did = d.decks.id('Languages') - chinese_did = d.decks.id('Chinese') - hsk_did = d.decks.id('Chinese::HSK') + languages_did = d.decks.id("Languages") + chinese_did = d.decks.id("Chinese") + hsk_did = d.decks.id("Chinese::HSK") # Renaming also renames children d.decks.renameForDragAndDrop(chinese_did, languages_did) - assert deckNames() == [ 'Languages', 'Languages::Chinese', 'Languages::Chinese::HSK' ] + assert deckNames() == ["Languages", "Languages::Chinese", "Languages::Chinese::HSK"] # Dragging a deck onto itself is a no-op d.decks.renameForDragAndDrop(languages_did, languages_did) - assert deckNames() == [ 'Languages', 'Languages::Chinese', 'Languages::Chinese::HSK' ] + assert deckNames() == ["Languages", "Languages::Chinese", "Languages::Chinese::HSK"] # Dragging a deck onto its parent is a no-op d.decks.renameForDragAndDrop(hsk_did, chinese_did) - assert deckNames() == [ 'Languages', 'Languages::Chinese', 'Languages::Chinese::HSK' ] + assert deckNames() == ["Languages", "Languages::Chinese", "Languages::Chinese::HSK"] # Dragging a deck onto a descendant is a no-op d.decks.renameForDragAndDrop(languages_did, hsk_did) - assert deckNames() == [ 'Languages', 'Languages::Chinese', 'Languages::Chinese::HSK' ] + assert deckNames() == ["Languages", "Languages::Chinese", "Languages::Chinese::HSK"] # Can drag a grandchild onto its grandparent. It becomes a child d.decks.renameForDragAndDrop(hsk_did, languages_did) - assert deckNames() == [ 'Languages', 'Languages::Chinese', 'Languages::HSK' ] + assert deckNames() == ["Languages", "Languages::Chinese", "Languages::HSK"] # Can drag a deck onto its sibling d.decks.renameForDragAndDrop(hsk_did, chinese_did) - assert deckNames() == [ 'Languages', 'Languages::Chinese', 'Languages::Chinese::HSK' ] + assert deckNames() == ["Languages", "Languages::Chinese", "Languages::Chinese::HSK"] # Can drag a deck back to the top level d.decks.renameForDragAndDrop(chinese_did, None) - assert deckNames() == [ 'Chinese', 'Chinese::HSK', 'Languages' ] + assert deckNames() == ["Chinese", "Chinese::HSK", "Languages"] # Dragging a top level deck to the top level is a no-op d.decks.renameForDragAndDrop(chinese_did, None) - assert deckNames() == [ 'Chinese', 'Chinese::HSK', 'Languages' ] + assert deckNames() == ["Chinese", "Chinese::HSK", "Languages"] # can't drack a deck where sibling have same name new_hsk_did = d.decks.id("HSK") - assertException(DeckRenameError, lambda: d.decks.renameForDragAndDrop(new_hsk_did, chinese_did)) + assertException( + DeckRenameError, lambda: d.decks.renameForDragAndDrop(new_hsk_did, chinese_did) + ) d.decks.rem(new_hsk_did) # can't drack a deck where sibling have same name different case new_hsk_did = d.decks.id("hsk") - assertException(DeckRenameError, lambda: d.decks.renameForDragAndDrop(new_hsk_did, chinese_did)) + assertException( + DeckRenameError, lambda: d.decks.renameForDragAndDrop(new_hsk_did, chinese_did) + ) d.decks.rem(new_hsk_did) # '' is a convenient alias for the top level DID - d.decks.renameForDragAndDrop(hsk_did, '') - assert deckNames() == [ 'Chinese', 'HSK', 'Languages' ] + d.decks.renameForDragAndDrop(hsk_did, "") + assert deckNames() == ["Chinese", "HSK", "Languages"] + def test_check(): d = getEmptyCol() diff --git a/tests/test_exporting.py b/tests/test_exporting.py index 005bdd011..dc7db3ff2 100644 --- a/tests/test_exporting.py +++ b/tests/test_exporting.py @@ -1,37 +1,48 @@ # coding: utf-8 -import nose, os, tempfile +import os +import tempfile + +from nose2.tools.decorators import with_setup + from anki import Collection as aopen from anki.exporting import * from anki.importing import Anki2Importer + from .shared import getEmptyCol deck = None ds = None testDir = os.path.dirname(__file__) + def setup1(): global deck deck = getEmptyCol() f = deck.newNote() - f['Front'] = "foo"; f['Back'] = "bar
"; f.tags = ["tag", "tag2"] + f["Front"] = "foo" + f["Back"] = "bar
" + f.tags = ["tag", "tag2"] deck.addNote(f) # with a different deck f = deck.newNote() - f['Front'] = "baz"; f['Back'] = "qux" - f.model()['did'] = deck.decks.id("new deck") + f["Front"] = "baz" + f["Back"] = "qux" + f.model()["did"] = deck.decks.id("new deck") deck.addNote(f) + ########################################################################## -@nose.with_setup(setup1) + +@with_setup(setup1) def test_export_anki(): # create a new deck with its own conf to test conf copying did = deck.decks.id("test") dobj = deck.decks.get(did) confId = deck.decks.confId("newconf") conf = deck.decks.getConf(confId) - conf['new']['perDay'] = 5 + conf["new"]["perDay"] = 5 deck.decks.save(conf) deck.decks.setConf(dobj, confId) # export @@ -43,7 +54,7 @@ def test_export_anki(): e.exportInto(newname) # exporting should not have changed conf for original deck conf = deck.decks.confForDid(did) - assert conf['id'] != 1 + assert conf["id"] != 1 # connect to new deck d2 = aopen(newname) assert d2.cardCount() == 2 @@ -51,10 +62,10 @@ def test_export_anki(): did = d2.decks.id("test", create=False) assert did conf2 = d2.decks.confForDid(did) - assert conf2['new']['perDay'] == 20 + assert conf2["new"]["perDay"] == 20 dobj = d2.decks.get(did) # conf should be 1 - assert dobj['conf'] == 1 + assert dobj["conf"] == 1 # try again, limited to a deck fd, newname = tempfile.mkstemp(prefix="ankitest", suffix=".anki2") newname = str(newname) @@ -65,13 +76,14 @@ def test_export_anki(): d2 = aopen(newname) assert d2.cardCount() == 1 -@nose.with_setup(setup1) + +@with_setup(setup1) def test_export_ankipkg(): # add a test file to the media folder with open(os.path.join(deck.media.dir(), "今日.mp3"), "w") as f: f.write("test") n = deck.newNote() - n['Front'] = '[sound:今日.mp3]' + n["Front"] = "[sound:今日.mp3]" deck.addNote(n) e = AnkiPackageExporter(deck) fd, newname = tempfile.mkstemp(prefix="ankitest", suffix=".apkg") @@ -80,13 +92,14 @@ def test_export_ankipkg(): os.unlink(newname) e.exportInto(newname) -@nose.with_setup(setup1) + +@with_setup(setup1) def test_export_anki_due(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = "foo" + f["Front"] = "foo" deck.addNote(f) - deck.crt -= 86400*10 + deck.crt -= 86400 * 10 deck.sched.reset() c = deck.sched.getCard() deck.sched.answerCard(c, 3) @@ -112,7 +125,8 @@ def test_export_anki_due(): deck2.sched.reset() assert c.due - deck2.sched.today == 1 -# @nose.with_setup(setup1) + +# @with_setup(setup1) # def test_export_textcard(): # e = TextCardExporter(deck) # f = unicode(tempfile.mkstemp(prefix="ankitest")[1]) @@ -121,7 +135,8 @@ def test_export_anki_due(): # e.includeTags = True # e.exportInto(f) -@nose.with_setup(setup1) + +@with_setup(setup1) def test_export_textnote(): e = TextNoteExporter(deck) fd, f = tempfile.mkstemp(prefix="ankitest") @@ -135,5 +150,6 @@ def test_export_textnote(): e.exportInto(f) assert open(f).readline() == "foo\tbar\n" + def test_exporters(): assert "*.apkg" in str(exporters()) diff --git a/tests/test_find.py b/tests/test_find.py index 9faa1b7bb..f1a79edee 100644 --- a/tests/test_find.py +++ b/tests/test_find.py @@ -1,9 +1,10 @@ # coding: utf-8 -from nose.tools import assert_raises +from nose2.tools.such import helper from anki.find import Finder from tests.shared import getEmptyCol + def test_parse(): f = Finder(None) assert f._tokenize("hello world") == ["hello", "world"] @@ -12,43 +13,54 @@ def test_parse(): assert f._tokenize("one --two") == ["one", "-", "two"] assert f._tokenize("one - two") == ["one", "-", "two"] assert f._tokenize("one or -two") == ["one", "or", "-", "two"] - assert f._tokenize("'hello \"world\"'") == ["hello \"world\""] + assert f._tokenize("'hello \"world\"'") == ['hello "world"'] assert f._tokenize('"hello world"') == ["hello world"] assert f._tokenize("one (two or ( three or four))") == [ - "one", "(", "two", "or", "(", "three", "or", "four", - ")", ")"] + "one", + "(", + "two", + "or", + "(", + "three", + "or", + "four", + ")", + ")", + ] assert f._tokenize("embedded'string") == ["embedded'string"] assert f._tokenize("deck:'two words'") == ["deck:two words"] + def test_findCards(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = 'dog' - f['Back'] = 'cat' + f["Front"] = "dog" + f["Back"] = "cat" f.tags.append("monkey animal_1 * %") f1id = f.id deck.addNote(f) firstCardId = f.cards()[0].id f = deck.newNote() - f['Front'] = 'goats are fun' - f['Back'] = 'sheep' + f["Front"] = "goats are fun" + f["Back"] = "sheep" f.tags.append("sheep goat horse animal11") deck.addNote(f) f2id = f.id f = deck.newNote() - f['Front'] = 'cat' - f['Back'] = 'sheep' + f["Front"] = "cat" + f["Back"] = "sheep" deck.addNote(f) catCard = f.cards()[0] - m = deck.models.current(); mm = deck.models + m = deck.models.current() + mm = deck.models t = mm.newTemplate("Reverse") - t['qfmt'] = "{{Back}}" - t['afmt'] = "{{Front}}" + t["qfmt"] = "{{Back}}" + t["afmt"] = "{{Front}}" mm.addTemplate(m, t) mm.save(m) f = deck.newNote() - f['Front'] = 'test' - f['Back'] = 'foo bar' + f["Front"] = "test" + f["Back"] = "foo bar" deck.addNote(f) latestCardIds = [c.id for c in f.cards()] # tag searches @@ -66,9 +78,7 @@ def test_findCards(): assert len(deck.findCards("tag:sheep -tag:monkey")) == 1 assert len(deck.findCards("-tag:sheep")) == 4 deck.tags.bulkAdd(deck.db.list("select id from notes"), "foo bar") - assert (len(deck.findCards("tag:foo")) == - len(deck.findCards("tag:bar")) == - 5) + assert len(deck.findCards("tag:foo")) == len(deck.findCards("tag:bar")) == 5 deck.tags.bulkRem(deck.db.list("select id from notes"), "foo") assert len(deck.findCards("tag:foo")) == 0 assert len(deck.findCards("tag:bar")) == 5 @@ -86,7 +96,8 @@ def test_findCards(): c.flush() assert deck.findCards("is:review") == [c.id] assert deck.findCards("is:due") == [] - c.due = 0; c.queue = 2 + c.due = 0 + c.queue = 2 c.flush() assert deck.findCards("is:due") == [c.id] assert len(deck.findCards("-is:due")) == 4 @@ -97,10 +108,10 @@ def test_findCards(): assert deck.findCards("is:suspended") == [c.id] # nids assert deck.findCards("nid:54321") == [] - assert len(deck.findCards("nid:%d"%f.id)) == 2 + assert len(deck.findCards("nid:%d" % f.id)) == 2 assert len(deck.findCards("nid:%d,%d" % (f1id, f2id))) == 2 # templates - with assert_raises(Exception): + with helper.assertRaises(Exception): deck.findCards("card:foo") assert len(deck.findCards("'card:card 1'")) == 4 assert len(deck.findCards("card:reverse")) == 1 @@ -115,16 +126,16 @@ def test_findCards(): assert len(deck.findCards("front:do")) == 0 assert len(deck.findCards("front:*")) == 5 # ordering - deck.conf['sortType'] = "noteCrt" + deck.conf["sortType"] = "noteCrt" assert deck.findCards("front:*", order=True)[-1] in latestCardIds assert deck.findCards("", order=True)[-1] in latestCardIds - deck.conf['sortType'] = "noteFld" + deck.conf["sortType"] = "noteFld" assert deck.findCards("", order=True)[0] == catCard.id assert deck.findCards("", order=True)[-1] in latestCardIds - deck.conf['sortType'] = "cardMod" + deck.conf["sortType"] = "cardMod" assert deck.findCards("", order=True)[-1] in latestCardIds assert deck.findCards("", order=True)[0] == firstCardId - deck.conf['sortBackwards'] = True + deck.conf["sortBackwards"] = True assert deck.findCards("", order=True)[0] in latestCardIds # model assert len(deck.findCards("note:basic")) == 5 @@ -136,29 +147,30 @@ def test_findCards(): assert len(deck.findCards("-deck:foo")) == 5 assert len(deck.findCards("deck:def*")) == 5 assert len(deck.findCards("deck:*EFAULT")) == 5 - with assert_raises(Exception): + with helper.assertRaises(Exception): deck.findCards("deck:*cefault") # full search f = deck.newNote() - f['Front'] = 'helloworld' - f['Back'] = 'abc' + f["Front"] = "helloworld" + f["Back"] = "abc" deck.addNote(f) # as it's the sort field, it matches assert len(deck.findCards("helloworld")) == 2 - #assert len(deck.findCards("helloworld", full=True)) == 2 + # assert len(deck.findCards("helloworld", full=True)) == 2 # if we put it on the back, it won't - (f['Front'], f['Back']) = (f['Back'], f['Front']) + (f["Front"], f["Back"]) = (f["Back"], f["Front"]) f.flush() assert len(deck.findCards("helloworld")) == 0 - #assert len(deck.findCards("helloworld", full=True)) == 2 - #assert len(deck.findCards("back:helloworld", full=True)) == 2 + # assert len(deck.findCards("helloworld", full=True)) == 2 + # assert len(deck.findCards("back:helloworld", full=True)) == 2 # searching for an invalid special tag should not error - with assert_raises(Exception): + with helper.assertRaises(Exception): len(deck.findCards("is:invalid")) # should be able to limit to parent deck, no children id = deck.db.scalar("select id from cards limit 1") - deck.db.execute("update cards set did = ? where id = ?", - deck.decks.id("Default::Child"), id) + deck.db.execute( + "update cards set did = ? where id = ?", deck.decks.id("Default::Child"), id + ) assert len(deck.findCards("deck:default")) == 7 assert len(deck.findCards("deck:default::child")) == 1 assert len(deck.findCards("deck:default -deck:default::*")) == 6 @@ -166,7 +178,9 @@ def test_findCards(): id = deck.db.scalar("select id from cards limit 1") deck.db.execute( "update cards set queue=2, ivl=10, reps=20, due=30, factor=2200 " - "where id = ?", id) + "where id = ?", + id, + ) assert len(deck.findCards("prop:ivl>5")) == 1 assert len(deck.findCards("prop:ivl<5")) > 1 assert len(deck.findCards("prop:ivl>=5")) == 1 @@ -205,8 +219,8 @@ def test_findCards(): # empty field assert len(deck.findCards("front:")) == 0 f = deck.newNote() - f['Front'] = '' - f['Back'] = 'abc2' + f["Front"] = "" + f["Back"] = "abc2" assert deck.addNote(f) == 1 assert len(deck.findCards("front:")) == 1 # OR searches and nesting @@ -220,60 +234,67 @@ def test_findCards(): assert len(deck.findCards("(()")) == 0 # added assert len(deck.findCards("added:0")) == 0 - deck.db.execute("update cards set id = id - 86400*1000 where id = ?", - id) + deck.db.execute("update cards set id = id - 86400*1000 where id = ?", id) assert len(deck.findCards("added:1")) == deck.cardCount() - 1 assert len(deck.findCards("added:2")) == deck.cardCount() # flag - with assert_raises(Exception): + with helper.assertRaises(Exception): deck.findCards("flag:01") - with assert_raises(Exception): + with helper.assertRaises(Exception): deck.findCards("flag:12") + def test_findReplace(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = 'foo' - f['Back'] = 'bar' + f["Front"] = "foo" + f["Back"] = "bar" deck.addNote(f) f2 = deck.newNote() - f2['Front'] = 'baz' - f2['Back'] = 'foo' + f2["Front"] = "baz" + f2["Back"] = "foo" deck.addNote(f2) nids = [f.id, f2.id] # should do nothing assert deck.findReplace(nids, "abc", "123") == 0 # global replace assert deck.findReplace(nids, "foo", "qux") == 2 - f.load(); assert f['Front'] == "qux" - f2.load(); assert f2['Back'] == "qux" + f.load() + assert f["Front"] == "qux" + f2.load() + assert f2["Back"] == "qux" # single field replace assert deck.findReplace(nids, "qux", "foo", field="Front") == 1 - f.load(); assert f['Front'] == "foo" - f2.load(); assert f2['Back'] == "qux" + f.load() + assert f["Front"] == "foo" + f2.load() + assert f2["Back"] == "qux" # regex replace assert deck.findReplace(nids, "B.r", "reg") == 0 - f.load(); assert f['Back'] != "reg" + f.load() + assert f["Back"] != "reg" assert deck.findReplace(nids, "B.r", "reg", regex=True) == 1 - f.load(); assert f['Back'] == "reg" + f.load() + assert f["Back"] == "reg" + def test_findDupes(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = 'foo' - f['Back'] = 'bar' + f["Front"] = "foo" + f["Back"] = "bar" deck.addNote(f) f2 = deck.newNote() - f2['Front'] = 'baz' - f2['Back'] = 'bar' + f2["Front"] = "baz" + f2["Back"] = "bar" deck.addNote(f2) f3 = deck.newNote() - f3['Front'] = 'quux' - f3['Back'] = 'bar' + f3["Front"] = "quux" + f3["Back"] = "bar" deck.addNote(f3) f4 = deck.newNote() - f4['Front'] = 'quuux' - f4['Back'] = 'nope' + f4["Front"] = "quuux" + f4["Back"] = "nope" deck.addNote(f4) r = deck.findDupes("Back") assert r[0][0] == "bar" diff --git a/tests/test_flags.py b/tests/test_flags.py index 11796b692..1fb988fc4 100644 --- a/tests/test_flags.py +++ b/tests/test_flags.py @@ -1,9 +1,11 @@ from tests.shared import assertException, getEmptyCol + def test_flags(): col = getEmptyCol() n = col.newNote() - n['Front'] = "one"; n['Back'] = "two" + n["Front"] = "one" + n["Back"] = "two" cnt = col.addNote(n) c = n.cards()[0] # make sure higher bits are preserved diff --git a/tests/test_importing.py b/tests/test_importing.py index 67218d994..78c1edb2e 100644 --- a/tests/test_importing.py +++ b/tests/test_importing.py @@ -1,22 +1,29 @@ # coding: utf-8 -import os -from tests.shared import getUpgradeDeckPath, getEmptyCol +import os + +from anki.importing import ( + Anki2Importer, + AnkiPackageImporter, + MnemosyneImporter, + SupermemoXmlImporter, + TextImporter, +) from anki.utils import ids2str -from anki.importing import Anki2Importer, TextImporter, \ - SupermemoXmlImporter, MnemosyneImporter, AnkiPackageImporter +from tests.shared import getEmptyCol, getUpgradeDeckPath testDir = os.path.dirname(__file__) -srcNotes=None -srcCards=None +srcNotes = None +srcCards = None + def test_anki2_mediadupes(): tmp = getEmptyCol() # add a note that references a sound n = tmp.newNote() - n['Front'] = "[sound:foo.mp3]" - mid = n.model()['id'] + n["Front"] = "[sound:foo.mp3]" + mid = n.model()["id"] tmp.addNote(n) # add that sound to media folder with open(os.path.join(tmp.media.dir(), "foo.mp3"), "w") as f: @@ -41,8 +48,7 @@ def test_anki2_mediadupes(): f.write("bar") imp = Anki2Importer(empty, tmp.path) imp.run() - assert sorted(os.listdir(empty.media.dir())) == [ - "foo.mp3", "foo_%s.mp3" % mid] + assert sorted(os.listdir(empty.media.dir())) == ["foo.mp3", "foo_%s.mp3" % mid] n = empty.getNote(empty.db.scalar("select id from notes")) assert "_" in n.fields[0] # if the localized media file already exists, we rewrite the note and @@ -52,25 +58,24 @@ def test_anki2_mediadupes(): f.write("bar") imp = Anki2Importer(empty, tmp.path) imp.run() - assert sorted(os.listdir(empty.media.dir())) == [ - "foo.mp3", "foo_%s.mp3" % mid] - assert sorted(os.listdir(empty.media.dir())) == [ - "foo.mp3", "foo_%s.mp3" % mid] + assert sorted(os.listdir(empty.media.dir())) == ["foo.mp3", "foo_%s.mp3" % mid] + assert sorted(os.listdir(empty.media.dir())) == ["foo.mp3", "foo_%s.mp3" % mid] n = empty.getNote(empty.db.scalar("select id from notes")) assert "_" in n.fields[0] + def test_apkg(): tmp = getEmptyCol() apkg = str(os.path.join(testDir, "support/media.apkg")) imp = AnkiPackageImporter(tmp, apkg) assert os.listdir(tmp.media.dir()) == [] imp.run() - assert os.listdir(tmp.media.dir()) == ['foo.wav'] + assert os.listdir(tmp.media.dir()) == ["foo.wav"] # importing again should be idempotent in terms of media tmp.remCards(tmp.db.list("select id from cards")) imp = AnkiPackageImporter(tmp, apkg) imp.run() - assert os.listdir(tmp.media.dir()) == ['foo.wav'] + assert os.listdir(tmp.media.dir()) == ["foo.wav"] # but if the local file has different data, it will rename tmp.remCards(tmp.db.list("select id from cards")) with open(os.path.join(tmp.media.dir(), "foo.wav"), "w") as f: @@ -79,6 +84,7 @@ def test_apkg(): imp.run() assert len(os.listdir(tmp.media.dir())) == 2 + def test_anki2_diffmodel_templates(): # different from the above as this one tests only the template text being # changed, not the number of cards/fields @@ -94,11 +100,12 @@ def test_anki2_diffmodel_templates(): imp.dupeOnSchemaChange = True imp.run() # collection should contain the note we imported - assert(dst.noteCount() == 1) + assert dst.noteCount() == 1 # the front template should contain the text added in the 2nd package - tcid = dst.findCards("")[0] # only 1 note in collection + tcid = dst.findCards("")[0] # only 1 note in collection tnote = dst.getCard(tcid).note() - assert("Changed Front Template" in dst.findTemplates(tnote)[0]['qfmt']) + assert "Changed Front Template" in dst.findTemplates(tnote)[0]["qfmt"] + def test_anki2_updates(): # create a new empty deck @@ -127,6 +134,7 @@ def test_anki2_updates(): assert dst.noteCount() == 1 assert dst.db.scalar("select flds from notes").startswith("goodbye") + def test_csv(): deck = getEmptyCol() file = str(os.path.join(testDir, "support/text-2fields.txt")) @@ -147,7 +155,7 @@ def test_csv(): n.flush() i.run() n.load() - assert n.tags == ['test'] + assert n.tags == ["test"] # if add-only mode, count will be 0 i.importMode = 1 i.run() @@ -161,6 +169,7 @@ def test_csv(): assert deck.cardCount() == 11 deck.close() + def test_csv2(): deck = getEmptyCol() mm = deck.models @@ -169,9 +178,9 @@ def test_csv2(): mm.addField(m, f) mm.save(m) n = deck.newNote() - n['Front'] = "1" - n['Back'] = "2" - n['Three'] = "3" + n["Front"] = "1" + n["Back"] = "2" + n["Three"] = "3" deck.addNote(n) # an update with unmapped fields should not clobber those fields file = str(os.path.join(testDir, "support/text-update.txt")) @@ -179,16 +188,17 @@ def test_csv2(): i.initMapping() i.run() n.load() - assert n['Front'] == "1" - assert n['Back'] == "x" - assert n['Three'] == "3" + assert n["Front"] == "1" + assert n["Back"] == "x" + assert n["Three"] == "3" deck.close() + def test_supermemo_xml_01_unicode(): deck = getEmptyCol() file = str(os.path.join(testDir, "support/supermemo1.xml")) i = SupermemoXmlImporter(deck, file) - #i.META.logToStdOutput = True + # i.META.logToStdOutput = True i.run() assert i.total == 1 cid = deck.db.scalar("select id from cards") @@ -198,6 +208,7 @@ def test_supermemo_xml_01_unicode(): assert c.reps == 7 deck.close() + def test_mnemo(): deck = getEmptyCol() file = str(os.path.join(testDir, "support/mnemo.db")) diff --git a/tests/test_latex.py b/tests/test_latex.py index 8b5937a20..d8e82b025 100644 --- a/tests/test_latex.py +++ b/tests/test_latex.py @@ -1,20 +1,21 @@ # coding: utf-8 import os - import shutil -from tests.shared import getEmptyCol from anki.utils import stripHTML +from tests.shared import getEmptyCol + def test_latex(): d = getEmptyCol() # change latex cmd to simulate broken build import anki.latex + anki.latex.pngCommands[0][0] = "nolatex" # add a note with latex f = d.newNote() - f['Front'] = "[latex]hello[/latex]" + f["Front"] = "[latex]hello[/latex]" d.addNote(f) # but since latex couldn't run, there's nothing there assert len(os.listdir(d.media.dir())) == 0 @@ -34,13 +35,13 @@ def test_latex(): assert ".png" in f.cards()[0].q() # adding new notes should cause generation on question display f = d.newNote() - f['Front'] = "[latex]world[/latex]" + f["Front"] = "[latex]world[/latex]" d.addNote(f) f.cards()[0].q() assert len(os.listdir(d.media.dir())) == 2 # another note with the same media should reuse f = d.newNote() - f['Front'] = " [latex]world[/latex]" + f["Front"] = " [latex]world[/latex]" d.addNote(f) assert len(os.listdir(d.media.dir())) == 2 oldcard = f.cards()[0] @@ -49,7 +50,7 @@ def test_latex(): # missing media will show the latex anki.latex.build = False f = d.newNote() - f['Front'] = "[latex]foo[/latex]" + f["Front"] = "[latex]foo[/latex]" d.addNote(f) assert len(os.listdir(d.media.dir())) == 2 assert stripHTML(f.cards()[0].q()) == "[latex]foo[/latex]" @@ -86,10 +87,11 @@ def test_latex(): (result, msg) = _test_includes_bad_command("\\emph") assert not result, msg + def _test_includes_bad_command(bad): d = getEmptyCol() f = d.newNote() - f['Front'] = '[latex]%s[/latex]' % bad + f["Front"] = "[latex]%s[/latex]" % bad d.addNote(f) q = f.cards()[0].q() return ("'%s' is not allowed on cards" % bad in q, "Card content: %s" % q) diff --git a/tests/test_media.py b/tests/test_media.py index e8198a543..89d595468 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -1,7 +1,7 @@ # coding: utf-8 -import tempfile import os +import tempfile import time from .shared import getEmptyCol, testDir @@ -23,6 +23,7 @@ def test_add(): f.write("world") assert d.media.addFile(path) == "foo (1).jpg" + def test_strings(): d = getEmptyCol() mf = d.media.filesInStr @@ -31,12 +32,16 @@ def test_strings(): assert mf(mid, "aoeuao") == ["foo.jpg"] assert mf(mid, "aoeuao") == ["foo.jpg"] assert mf(mid, "aoeuao") == [ - "foo.jpg", "bar.jpg"] + "foo.jpg", + "bar.jpg", + ] assert mf(mid, "aoeuao") == ["foo.jpg"] assert mf(mid, "") == ["one", "two"] - assert mf(mid, "aoeuao") == ["foo.jpg"] - assert mf(mid, "aoeuao") == [ - "foo.jpg", "fo"] + assert mf(mid, 'aoeuao') == ["foo.jpg"] + assert mf(mid, 'aoeuao') == [ + "foo.jpg", + "fo", + ] assert mf(mid, "aou[sound:foo.mp3]aou") == ["foo.mp3"] sp = d.media.strip assert sp("aoeu") == "aoeu" @@ -47,6 +52,7 @@ def test_strings(): assert es("") == "" assert es('') == '' + def test_deckIntegration(): d = getEmptyCol() # create a media dir @@ -56,11 +62,13 @@ def test_deckIntegration(): d.media.addFile(file) # add a note which references it f = d.newNote() - f['Front'] = "one"; f['Back'] = "" + f["Front"] = "one" + f["Back"] = "" d.addNote(f) # and one which references a non-existent file f = d.newNote() - f['Front'] = "one"; f['Back'] = "" + f["Front"] = "one" + f["Back"] = "" d.addNote(f) # and add another file which isn't used with open(os.path.join(d.media.dir(), "foo.jpg"), "w") as f: @@ -70,12 +78,16 @@ def test_deckIntegration(): assert ret[0] == ["fake2.png"] assert ret[1] == ["foo.jpg"] + def test_changes(): d = getEmptyCol() + def added(): return d.media.db.execute("select fname from media where csum is not null") + def removed(): return d.media.db.execute("select fname from media where csum is null") + assert not list(added()) assert not list(removed()) # add a file @@ -97,26 +109,27 @@ def test_changes(): assert not list(removed()) # but if we add another file, it will time.sleep(1) - with open(path+"2", "w") as f: + with open(path + "2", "w") as f: f.write("yo") d.media.findChanges() assert len(list(added())) == 2 assert not list(removed()) # deletions should get noticed too time.sleep(1) - os.unlink(path+"2") + os.unlink(path + "2") d.media.findChanges() assert len(list(added())) == 1 assert len(list(removed())) == 1 + def test_illegal(): d = getEmptyCol() aString = "a:b|cd\\e/f\0g*h" good = "abcdefgh" assert d.media.stripIllegal(aString) == good for c in aString: - bad = d.media.hasIllegal("somestring"+c+"morestring") + bad = d.media.hasIllegal("somestring" + c + "morestring") if bad: - assert(c not in good) + assert c not in good else: - assert(c in good) + assert c in good diff --git a/tests/test_models.py b/tests/test_models.py index 9df8f7ef6..f527bf389 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,43 +1,47 @@ # coding: utf-8 +import time -from tests.shared import getEmptyCol -from anki.consts import MODEL_CLOZE -from anki.utils import stripHTML, joinFields import anki.template +from anki.consts import MODEL_CLOZE +from anki.utils import isWin, joinFields, stripHTML +from tests.shared import getEmptyCol + def test_modelDelete(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = '1' - f['Back'] = '2' + f["Front"] = "1" + f["Back"] = "2" deck.addNote(f) assert deck.cardCount() == 1 deck.models.rem(deck.models.current()) assert deck.cardCount() == 0 + def test_modelCopy(): deck = getEmptyCol() m = deck.models.current() m2 = deck.models.copy(m) - assert m2['name'] == "Basic copy" - assert m2['id'] != m['id'] - assert len(m2['flds']) == 2 - assert len(m['flds']) == 2 - assert len(m2['flds']) == len(m['flds']) - assert len(m['tmpls']) == 1 - assert len(m2['tmpls']) == 1 + assert m2["name"] == "Basic copy" + assert m2["id"] != m["id"] + assert len(m2["flds"]) == 2 + assert len(m["flds"]) == 2 + assert len(m2["flds"]) == len(m["flds"]) + assert len(m["tmpls"]) == 1 + assert len(m2["tmpls"]) == 1 assert deck.models.scmhash(m) == deck.models.scmhash(m2) + def test_fields(): d = getEmptyCol() f = d.newNote() - f['Front'] = '1' - f['Back'] = '2' + f["Front"] = "1" + f["Back"] = "2" d.addNote(f) m = d.models.current() # make sure renaming a field updates the templates - d.models.renameField(m, m['flds'][0], "NewFront") - assert "{{NewFront}}" in m['tmpls'][0]['qfmt'] + d.models.renameField(m, m["flds"][0], "NewFront") + assert "{{NewFront}}" in m["tmpls"][0]["qfmt"] h = d.models.scmhash(m) # add a field f = d.models.newField("foo") @@ -46,44 +50,46 @@ def test_fields(): assert d.models.scmhash(m) != h # rename it d.models.renameField(m, f, "bar") - assert d.getNote(d.models.nids(m)[0])['bar'] == '' + assert d.getNote(d.models.nids(m)[0])["bar"] == "" # delete back - d.models.remField(m, m['flds'][1]) + d.models.remField(m, m["flds"][1]) assert d.getNote(d.models.nids(m)[0]).fields == ["1", ""] # move 0 -> 1 - d.models.moveField(m, m['flds'][0], 1) + d.models.moveField(m, m["flds"][0], 1) assert d.getNote(d.models.nids(m)[0]).fields == ["", "1"] # move 1 -> 0 - d.models.moveField(m, m['flds'][1], 0) + d.models.moveField(m, m["flds"][1], 0) assert d.getNote(d.models.nids(m)[0]).fields == ["1", ""] # add another and put in middle f = d.models.newField("baz") d.models.addField(m, f) f = d.getNote(d.models.nids(m)[0]) - f['baz'] = "2" + f["baz"] = "2" f.flush() assert d.getNote(d.models.nids(m)[0]).fields == ["1", "", "2"] # move 2 -> 1 - d.models.moveField(m, m['flds'][2], 1) + d.models.moveField(m, m["flds"][2], 1) assert d.getNote(d.models.nids(m)[0]).fields == ["1", "2", ""] # move 0 -> 2 - d.models.moveField(m, m['flds'][0], 2) + d.models.moveField(m, m["flds"][0], 2) assert d.getNote(d.models.nids(m)[0]).fields == ["2", "", "1"] # move 0 -> 1 - d.models.moveField(m, m['flds'][0], 1) + d.models.moveField(m, m["flds"][0], 1) assert d.getNote(d.models.nids(m)[0]).fields == ["", "2", "1"] + def test_templates(): d = getEmptyCol() - m = d.models.current(); mm = d.models + m = d.models.current() + mm = d.models t = mm.newTemplate("Reverse") - t['qfmt'] = "{{Back}}" - t['afmt'] = "{{Front}}" + t["qfmt"] = "{{Back}}" + t["afmt"] = "{{Front}}" mm.addTemplate(m, t) mm.save(m) f = d.newNote() - f['Front'] = '1' - f['Back'] = '2' + f["Front"] = "1" + f["Back"] = "2" d.addNote(f) assert d.cardCount() == 2 (c, c2) = f.cards() @@ -92,11 +98,12 @@ def test_templates(): assert c2.ord == 1 # switch templates d.models.moveTemplate(m, c.template(), 1) - c.load(); c2.load() + c.load() + c2.load() assert c.ord == 1 assert c2.ord == 0 # removing a template should delete its cards - assert d.models.remTemplate(m, m['tmpls'][0]) + assert d.models.remTemplate(m, m["tmpls"][0]) assert d.cardCount() == 1 # and should have updated the other cards' ordinals c = f.cards()[0] @@ -105,64 +112,67 @@ def test_templates(): # it shouldn't be possible to orphan notes by removing templates t = mm.newTemplate("template name") mm.addTemplate(m, t) - assert not d.models.remTemplate(m, m['tmpls'][0]) + assert not d.models.remTemplate(m, m["tmpls"][0]) + def test_cloze_ordinals(): d = getEmptyCol() d.models.setCurrent(d.models.byName("Cloze")) - m = d.models.current(); mm = d.models - - #We replace the default Cloze template + m = d.models.current() + mm = d.models + + # We replace the default Cloze template t = mm.newTemplate("ChainedCloze") - t['qfmt'] = "{{text:cloze:Text}}" - t['afmt'] = "{{text:cloze:Text}}" + t["qfmt"] = "{{text:cloze:Text}}" + t["afmt"] = "{{text:cloze:Text}}" mm.addTemplate(m, t) mm.save(m) - d.models.remTemplate(m, m['tmpls'][0]) - + d.models.remTemplate(m, m["tmpls"][0]) + f = d.newNote() - f['Text'] = '{{c1::firstQ::firstA}}{{c2::secondQ::secondA}}' + f["Text"] = "{{c1::firstQ::firstA}}{{c2::secondQ::secondA}}" d.addNote(f) assert d.cardCount() == 2 (c, c2) = f.cards() # first card should have first ord assert c.ord == 0 assert c2.ord == 1 - + def test_text(): d = getEmptyCol() m = d.models.current() - m['tmpls'][0]['qfmt'] = "{{text:Front}}" + m["tmpls"][0]["qfmt"] = "{{text:Front}}" d.models.save(m) f = d.newNote() - f['Front'] = 'helloworld' + f["Front"] = "helloworld" d.addNote(f) assert "helloworld" in f.cards()[0].q() + def test_cloze(): d = getEmptyCol() d.models.setCurrent(d.models.byName("Cloze")) f = d.newNote() - assert f.model()['name'] == "Cloze" + assert f.model()["name"] == "Cloze" # a cloze model with no clozes is not empty - f['Text'] = 'nothing' + f["Text"] = "nothing" assert d.addNote(f) # try with one cloze f = d.newNote() - f['Text'] = "hello {{c1::world}}" + f["Text"] = "hello {{c1::world}}" assert d.addNote(f) == 1 assert "hello [...]" in f.cards()[0].q() assert "hello world" in f.cards()[0].a() # and with a comment f = d.newNote() - f['Text'] = "hello {{c1::world::typical}}" + f["Text"] = "hello {{c1::world::typical}}" assert d.addNote(f) == 1 assert "[typical]" in f.cards()[0].q() assert "world" in f.cards()[0].a() # and with 2 clozes f = d.newNote() - f['Text'] = "hello {{c1::world}} {{c2::bar}}" + f["Text"] = "hello {{c1::world}} {{c2::bar}}" assert d.addNote(f) == 2 (c1, c2) = f.cards() assert "[...] bar" in c1.q() @@ -172,25 +182,27 @@ def test_cloze(): # if there are multiple answers for a single cloze, they are given in a # list f = d.newNote() - f['Text'] = "a {{c1::b}} {{c1::c}}" + f["Text"] = "a {{c1::b}} {{c1::c}}" assert d.addNote(f) == 1 - assert "b c" in ( - f.cards()[0].a()) + assert "b c" in (f.cards()[0].a()) # if we add another cloze, a card should be generated cnt = d.cardCount() - f['Text'] = "{{c2::hello}} {{c1::foo}}" + f["Text"] = "{{c2::hello}} {{c1::foo}}" f.flush() assert d.cardCount() == cnt + 1 # 0 or negative indices are not supported - f['Text'] += "{{c0::zero}} {{c-1:foo}}" + f["Text"] += "{{c0::zero}} {{c-1:foo}}" f.flush() assert len(f.cards()) == 2 + def test_cloze_mathjax(): d = getEmptyCol() d.models.setCurrent(d.models.byName("Cloze")) f = d.newNote() - f['Text'] = r'{{c1::ok}} \(2^2\) {{c2::not ok}} \(2^{{c3::2}}\) \(x^3\) {{c4::blah}} {{c5::text with \(x^2\) jax}}' + f[ + "Text" + ] = r"{{c1::ok}} \(2^2\) {{c2::not ok}} \(2^{{c3::2}}\) \(x^3\) {{c4::blah}} {{c5::text with \(x^2\) jax}}" assert d.addNote(f) assert len(f.cards()) == 5 assert "class=cloze" in f.cards()[0].q() @@ -200,56 +212,70 @@ def test_cloze_mathjax(): assert "class=cloze" in f.cards()[4].q() f = d.newNote() - f['Text'] = r'\(a\) {{c1::b}} \[ {{c1::c}} \]' + f["Text"] = r"\(a\) {{c1::b}} \[ {{c1::c}} \]" assert d.addNote(f) assert len(f.cards()) == 1 - assert f.cards()[0].q().endswith('\(a\) [...] \[ [...] \]') + assert f.cards()[0].q().endswith("\(a\) [...] \[ [...] \]") def test_chained_mods(): d = getEmptyCol() d.models.setCurrent(d.models.byName("Cloze")) - m = d.models.current(); mm = d.models - - #We replace the default Cloze template + m = d.models.current() + mm = d.models + + # We replace the default Cloze template t = mm.newTemplate("ChainedCloze") - t['qfmt'] = "{{cloze:text:Text}}" - t['afmt'] = "{{cloze:text:Text}}" + t["qfmt"] = "{{cloze:text:Text}}" + t["afmt"] = "{{cloze:text:Text}}" mm.addTemplate(m, t) mm.save(m) - d.models.remTemplate(m, m['tmpls'][0]) - + d.models.remTemplate(m, m["tmpls"][0]) + f = d.newNote() - q1 = 'phrase' - a1 = 'sentence' - q2 = 'en chaine' - a2 = 'chained' - f['Text'] = "This {{c1::%s::%s}} demonstrates {{c1::%s::%s}} clozes." % (q1,a1,q2,a2) + q1 = 'phrase' + a1 = "sentence" + q2 = 'en chaine' + a2 = "chained" + f["Text"] = "This {{c1::%s::%s}} demonstrates {{c1::%s::%s}} clozes." % ( + q1, + a1, + q2, + a2, + ) assert d.addNote(f) == 1 - assert "This [sentence] demonstrates [chained] clozes." in f.cards()[0].q() - assert "This phrase demonstrates en chaine clozes." in f.cards()[0].a() + assert ( + "This [sentence] demonstrates [chained] clozes." + in f.cards()[0].q() + ) + assert ( + "This phrase demonstrates en chaine clozes." + in f.cards()[0].a() + ) + def test_modelChange(): deck = getEmptyCol() basic = deck.models.byName("Basic") cloze = deck.models.byName("Cloze") # enable second template and add a note - m = deck.models.current(); mm = deck.models + m = deck.models.current() + mm = deck.models t = mm.newTemplate("Reverse") - t['qfmt'] = "{{Back}}" - t['afmt'] = "{{Front}}" + t["qfmt"] = "{{Back}}" + t["afmt"] = "{{Front}}" mm.addTemplate(m, t) mm.save(m) f = deck.newNote() - f['Front'] = 'f' - f['Back'] = 'b123' + f["Front"] = "f" + f["Back"] = "b123" deck.addNote(f) # switch fields map = {0: 1, 1: 0} deck.models.change(basic, [f.id], basic, map, None) f.load() - assert f['Front'] == 'b123' - assert f['Back'] == 'f' + assert f["Front"] == "b123" + assert f["Back"] == "f" # switch cards c0 = f.cards()[0] c1 = f.cards()[1] @@ -258,7 +284,9 @@ def test_modelChange(): assert c0.ord == 0 assert c1.ord == 1 deck.models.change(basic, [f.id], basic, None, map) - f.load(); c0.load(); c1.load() + f.load() + c0.load() + c1.load() assert "f" in c0.q() assert "b123" in c1.q() assert c0.ord == 1 @@ -267,6 +295,9 @@ def test_modelChange(): assert f.cards()[0].id == c1.id # delete first card map = {0: None, 1: 1} + if isWin: + # The low precision timer on Windows reveals a race condition + time.sleep(0.05) deck.models.change(basic, [f.id], basic, None, map) f.load() c0.load() @@ -279,30 +310,31 @@ def test_modelChange(): # but we have two cards, as a new one was generated assert len(f.cards()) == 2 # an unmapped field becomes blank - assert f['Front'] == 'b123' - assert f['Back'] == 'f' + assert f["Front"] == "b123" + assert f["Back"] == "f" deck.models.change(basic, [f.id], basic, map, None) f.load() - assert f['Front'] == '' - assert f['Back'] == 'f' + assert f["Front"] == "" + assert f["Back"] == "f" # another note to try model conversion f = deck.newNote() - f['Front'] = 'f2' - f['Back'] = 'b2' + f["Front"] = "f2" + f["Back"] = "b2" deck.addNote(f) assert deck.models.useCount(basic) == 2 assert deck.models.useCount(cloze) == 0 map = {0: 0, 1: 1} deck.models.change(basic, [f.id], cloze, map, map) f.load() - assert f['Text'] == "f2" + assert f["Text"] == "f2" assert len(f.cards()) == 2 # back the other way, with deletion of second ord - deck.models.remTemplate(basic, basic['tmpls'][1]) + deck.models.remTemplate(basic, basic["tmpls"][1]) assert deck.db.scalar("select count() from cards where nid = ?", f.id) == 2 deck.models.change(cloze, [f.id], basic, map, map) assert deck.db.scalar("select count() from cards where nid = ?", f.id) == 1 + def test_templates(): d = dict(Foo="x", Bar="y") assert anki.template.render("{{Foo}}", d) == "x" @@ -311,68 +343,83 @@ def test_templates(): assert anki.template.render("{{#Bar}}{{#Foo}}{{Foo}}{{/Foo}}{{/Bar}}", d) == "x" assert anki.template.render("{{#Baz}}{{#Foo}}{{Foo}}{{/Foo}}{{/Baz}}", d) == "" + def test_availOrds(): d = getEmptyCol() - m = d.models.current(); mm = d.models - t = m['tmpls'][0] + m = d.models.current() + mm = d.models + t = m["tmpls"][0] f = d.newNote() - f['Front'] = "1" + f["Front"] = "1" # simple templates assert mm.availOrds(m, joinFields(f.fields)) == [0] - t['qfmt'] = "{{Back}}" + t["qfmt"] = "{{Back}}" mm.save(m, templates=True) assert not mm.availOrds(m, joinFields(f.fields)) # AND - t['qfmt'] = "{{#Front}}{{#Back}}{{Front}}{{/Back}}{{/Front}}" + t["qfmt"] = "{{#Front}}{{#Back}}{{Front}}{{/Back}}{{/Front}}" mm.save(m, templates=True) assert not mm.availOrds(m, joinFields(f.fields)) - t['qfmt'] = "{{#Front}}\n{{#Back}}\n{{Front}}\n{{/Back}}\n{{/Front}}" + t["qfmt"] = "{{#Front}}\n{{#Back}}\n{{Front}}\n{{/Back}}\n{{/Front}}" mm.save(m, templates=True) assert not mm.availOrds(m, joinFields(f.fields)) # OR - t['qfmt'] = "{{Front}}\n{{Back}}" + t["qfmt"] = "{{Front}}\n{{Back}}" mm.save(m, templates=True) assert mm.availOrds(m, joinFields(f.fields)) == [0] - t['Front'] = "" - t['Back'] = "1" + t["Front"] = "" + t["Back"] = "1" assert mm.availOrds(m, joinFields(f.fields)) == [0] + def test_req(): def reqSize(model): - if model['type'] == MODEL_CLOZE: + if model["type"] == MODEL_CLOZE: return - assert (len(model['tmpls']) == len(model['req'])) + assert len(model["tmpls"]) == len(model["req"]) d = getEmptyCol() mm = d.models basic = mm.byName("Basic") - assert 'req' in basic + assert "req" in basic reqSize(basic) - assert basic['req'][0] == [0, 'all', [0]] + r = basic["req"][0] + assert r[0] == 0 + assert r[1] in ("any", "all") + assert r[2] == [0] opt = mm.byName("Basic (optional reversed card)") reqSize(opt) - assert opt['req'][0] == [0, 'all', [0]] - assert opt['req'][1] == [1, 'all', [1, 2]] - #testing any - opt['tmpls'][1]['qfmt'] = "{{Back}}{{Add Reverse}}" + r = opt["req"][0] + assert r[1] in ("any", "all") + assert r[2] == [0] + assert opt["req"][1] == [1, "all", [1, 2]] + # testing any + opt["tmpls"][1]["qfmt"] = "{{Back}}{{Add Reverse}}" mm.save(opt, templates=True) - assert opt['req'][1] == [1, 'any', [1, 2]] - #testing None - opt['tmpls'][1]['qfmt'] = "{{^Add Reverse}}{{Back}}{{/Add Reverse}}" + assert opt["req"][1] == [1, "any", [1, 2]] + # testing None + opt["tmpls"][1]["qfmt"] = "{{^Add Reverse}}{{Back}}{{/Add Reverse}}" mm.save(opt, templates=True) - assert opt['req'][1] == [1, 'none', []] + assert opt["req"][1] == [1, "none", []] -def test_updatereqs_performance(): - import time - d = getEmptyCol() - mm = d.models - m = mm.byName("Basic") - for i in range(100): - fld = mm.newField(f"field{i}") - mm.addField(m, fld) - tmpl = mm.newTemplate(f"template{i}") - tmpl['qfmt'] = "{{field%s}}" % i - mm.addTemplate(m, tmpl) - t = time.time() - mm.save(m, templates=True) - print("took", (time.time()-t)*100) \ No newline at end of file + opt = mm.byName("Basic (type in the answer)") + reqSize(opt) + r = opt["req"][0] + assert r[1] in ("any", "all") + assert r[2] == [0] + + +# def test_updatereqs_performance(): +# import time +# d = getEmptyCol() +# mm = d.models +# m = mm.byName("Basic") +# for i in range(100): +# fld = mm.newField(f"field{i}") +# mm.addField(m, fld) +# tmpl = mm.newTemplate(f"template{i}") +# tmpl['qfmt'] = "{{field%s}}" % i +# mm.addTemplate(m, tmpl) +# t = time.time() +# mm.save(m, templates=True) +# print("took", (time.time()-t)*100) diff --git a/tests/test_schedv1.py b/tests/test_schedv1.py index 4a2c00b3b..14b8982f6 100644 --- a/tests/test_schedv1.py +++ b/tests/test_schedv1.py @@ -1,39 +1,45 @@ # coding: utf-8 -import time import copy +import time from anki.consts import STARTING_FACTOR -from tests.shared import getEmptyCol as getEmptyColOrig -from anki.utils import intTime from anki.hooks import addHook +from anki.utils import intTime +from tests.shared import getEmptyCol as getEmptyColOrig + def getEmptyCol(): col = getEmptyColOrig() col.changeSchedulerVer(1) return col + def test_clock(): d = getEmptyCol() - if (d.sched.dayCutoff - intTime()) < 10*60: + if (d.sched.dayCutoff - intTime()) < 10 * 60: raise Exception("Unit tests will fail around the day rollover.") + def checkRevIvl(d, c, targetIvl): min, max = d.sched._fuzzIvlRange(targetIvl) return min <= c.ivl <= max + def test_basics(): d = getEmptyCol() d.reset() assert not d.sched.getCard() + def test_new(): d = getEmptyCol() d.reset() assert d.sched.newCount == 0 # add a note f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) d.reset() assert d.sched.newCount == 1 @@ -71,15 +77,16 @@ def test_new(): # assert qs[n] in c.q() # d.sched.answerCard(c, 2) + def test_newLimits(): d = getEmptyCol() # add some notes g2 = d.decks.id("Default::foo") for i in range(30): f = d.newNote() - f['Front'] = str(i) + f["Front"] = str(i) if i > 4: - f.model()['did'] = g2 + f.model()["did"] = g2 d.addNote(f) # give the child deck a different configuration c2 = d.decks.confId("new conf") @@ -92,33 +99,36 @@ def test_newLimits(): assert c.did == 1 # limit the parent to 10 cards, meaning we get 10 in total conf1 = d.decks.confForDid(1) - conf1['new']['perDay'] = 10 + conf1["new"]["perDay"] = 10 d.reset() assert d.sched.newCount == 10 # if we limit child to 4, we should get 9 conf2 = d.decks.confForDid(g2) - conf2['new']['perDay'] = 4 + conf2["new"]["perDay"] = 4 d.reset() assert d.sched.newCount == 9 + def test_newBoxes(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) d.reset() c = d.sched.getCard() - d.sched._cardConf(c)['new']['delays'] = [1,2,3,4,5] + d.sched._cardConf(c)["new"]["delays"] = [1, 2, 3, 4, 5] d.sched.answerCard(c, 2) # should handle gracefully - d.sched._cardConf(c)['new']['delays'] = [1] + d.sched._cardConf(c)["new"]["delays"] = [1] d.sched.answerCard(c, 2) + def test_learn(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" f = d.addNote(f) # set as a learn card and rebuild queues d.db.execute("update cards set queue=0, type=0") @@ -126,12 +136,12 @@ def test_learn(): # sched.getCard should return it, since it's due in the past c = d.sched.getCard() assert c - d.sched._cardConf(c)['new']['delays'] = [0.5, 3, 10] + d.sched._cardConf(c)["new"]["delays"] = [0.5, 3, 10] # fail it d.sched.answerCard(c, 1) # it should have three reps left to graduation - assert c.left%1000 == 3 - assert c.left//1000 == 3 + assert c.left % 1000 == 3 + assert c.left // 1000 == 3 # it should by due in 30 seconds t = round(c.due - time.time()) assert t >= 25 and t <= 40 @@ -139,8 +149,8 @@ def test_learn(): d.sched.answerCard(c, 2) # it should by due in 3 minutes assert round(c.due - time.time()) in (179, 180) - assert c.left%1000 == 2 - assert c.left//1000 == 2 + assert c.left % 1000 == 2 + assert c.left // 1000 == 2 # check log is accurate log = d.db.first("select * from revlog order by id desc") assert log[3] == 2 @@ -150,8 +160,8 @@ def test_learn(): d.sched.answerCard(c, 2) # it should by due in 10 minutes assert round(c.due - time.time()) in (599, 600) - assert c.left%1000 == 1 - assert c.left//1000 == 1 + assert c.left % 1000 == 1 + assert c.left // 1000 == 1 # the next pass should graduate the card assert c.queue == 1 assert c.type == 1 @@ -159,7 +169,7 @@ def test_learn(): assert c.queue == 2 assert c.type == 2 # should be due tomorrow, with an interval of 1 - assert c.due == d.sched.today+1 + assert c.due == d.sched.today + 1 assert c.ivl == 1 # or normal removal c.type = 0 @@ -188,14 +198,15 @@ def test_learn(): assert c.queue == 2 assert c.due == 321 + def test_learn_collapsed(): d = getEmptyCol() # add 2 notes f = d.newNote() - f['Front'] = "1" + f["Front"] = "1" f = d.addNote(f) f = d.newNote() - f['Front'] = "2" + f["Front"] = "2" f = d.addNote(f) # set as a learn card and rebuild queues d.db.execute("update cards set queue=0, type=0") @@ -214,27 +225,28 @@ def test_learn_collapsed(): c = d.sched.getCard() assert not c.q().endswith("2") + def test_learn_day(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" f = d.addNote(f) d.sched.reset() c = d.sched.getCard() - d.sched._cardConf(c)['new']['delays'] = [1, 10, 1440, 2880] + d.sched._cardConf(c)["new"]["delays"] = [1, 10, 1440, 2880] # pass it d.sched.answerCard(c, 2) # two reps to graduate, 1 more today - assert c.left%1000 == 3 - assert c.left//1000 == 1 + assert c.left % 1000 == 3 + assert c.left // 1000 == 1 assert d.sched.counts() == (0, 1, 0) c = d.sched.getCard() ni = d.sched.nextIvl assert ni(c, 2) == 86400 # answering it will place it in queue 3 d.sched.answerCard(c, 2) - assert c.due == d.sched.today+1 + assert c.due == d.sched.today + 1 assert c.queue == 3 assert not d.sched.getCard() # for testing, move it back a day @@ -244,7 +256,7 @@ def test_learn_day(): assert d.sched.counts() == (0, 1, 0) c = d.sched.getCard() # nextIvl should work - assert ni(c, 2) == 86400*2 + assert ni(c, 2) == 86400 * 2 # if we fail it, it should be back in the correct queue d.sched.answerCard(c, 1) assert c.queue == 1 @@ -266,17 +278,19 @@ def test_learn_day(): c.flush() d.reset() assert d.sched.counts() == (0, 0, 1) - d.sched._cardConf(c)['lapse']['delays'] = [1440] + d.sched._cardConf(c)["lapse"]["delays"] = [1440] c = d.sched.getCard() d.sched.answerCard(c, 1) assert c.queue == 3 assert d.sched.counts() == (0, 0, 0) + def test_reviews(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) # set the card up as a review card, due 8 days ago c = f.cards()[0] @@ -295,7 +309,7 @@ def test_reviews(): ################################################## # different delay to new d.reset() - d.sched._cardConf(c)['lapse']['delays'] = [2, 20] + d.sched._cardConf(c)["lapse"]["delays"] = [2, 20] d.sched.answerCard(c, 1) assert c.queue == 1 # it should be due tomorrow, with an interval of 1 @@ -313,7 +327,7 @@ def test_reviews(): # check ests. ni = d.sched.nextIvl assert ni(c, 1) == 120 - assert ni(c, 2) == 20*60 + assert ni(c, 2) == 20 * 60 # try again with an ease of 2 instead ################################################## c = copy.copy(cardcopy) @@ -355,8 +369,10 @@ def test_reviews(): c.flush() # steup hook hooked = [] + def onLeech(card): hooked.append(1) + addHook("leech", onLeech) d.sched.answerCard(c, 1) assert hooked @@ -364,10 +380,11 @@ def test_reviews(): c.load() assert c.queue == -1 + def test_button_spacing(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # 1 day ivl review card due now c = f.cards()[0] @@ -384,13 +401,14 @@ def test_button_spacing(): assert ni(c, 3) == "3 days" assert ni(c, 4) == "4 days" + def test_overdue_lapse(): # disabled in commit 3069729776990980f34c25be66410e947e9d51a2 return d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # simulate a review that was lapsed and is now due for its normal review c = f.cards()[0] @@ -419,13 +437,15 @@ def test_overdue_lapse(): d.sched.reset() assert d.sched.counts() == (0, 0, 1) + def test_finished(): d = getEmptyCol() # nothing due assert "Congratulations" in d.sched.finishedMsg() assert "limit" not in d.sched.finishedMsg() f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) # have a new card assert "new cards available" in d.sched.finishedMsg() @@ -438,44 +458,46 @@ def test_finished(): assert "Congratulations" in d.sched.finishedMsg() assert "limit" not in d.sched.finishedMsg() + def test_nextIvl(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) d.reset() conf = d.decks.confForDid(1) - conf['new']['delays'] = [0.5, 3, 10] - conf['lapse']['delays'] = [1, 5, 9] + conf["new"]["delays"] = [0.5, 3, 10] + conf["lapse"]["delays"] = [1, 5, 9] c = d.sched.getCard() # new cards ################################################## ni = d.sched.nextIvl assert ni(c, 1) == 30 assert ni(c, 2) == 180 - assert ni(c, 3) == 4*86400 + assert ni(c, 3) == 4 * 86400 d.sched.answerCard(c, 1) # cards in learning ################################################## assert ni(c, 1) == 30 assert ni(c, 2) == 180 - assert ni(c, 3) == 4*86400 + assert ni(c, 3) == 4 * 86400 d.sched.answerCard(c, 2) assert ni(c, 1) == 30 assert ni(c, 2) == 600 - assert ni(c, 3) == 4*86400 + assert ni(c, 3) == 4 * 86400 d.sched.answerCard(c, 2) # normal graduation is tomorrow - assert ni(c, 2) == 1*86400 - assert ni(c, 3) == 4*86400 + assert ni(c, 2) == 1 * 86400 + assert ni(c, 3) == 4 * 86400 # lapsed cards ################################################## c.type = 2 c.ivl = 100 c.factor = STARTING_FACTOR assert ni(c, 1) == 60 - assert ni(c, 2) == 100*86400 - assert ni(c, 3) == 100*86400 + assert ni(c, 2) == 100 * 86400 + assert ni(c, 3) == 100 * 86400 # review cards ################################################## c.queue = 2 @@ -484,8 +506,8 @@ def test_nextIvl(): # failing it should put it at 60s assert ni(c, 1) == 60 # or 1 day if relearn is false - d.sched._cardConf(c)['lapse']['delays']=[] - assert ni(c, 1) == 1*86400 + d.sched._cardConf(c)["lapse"]["delays"] = [] + assert ni(c, 1) == 1 * 86400 # (* 100 1.2 86400)10368000.0 assert ni(c, 2) == 10368000 # (* 100 2.5 86400)21600000.0 @@ -494,10 +516,11 @@ def test_nextIvl(): assert ni(c, 4) == 28080000 assert d.sched.nextIvlStr(c, 4) == "10.8 months" + def test_misc(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] # burying @@ -508,10 +531,11 @@ def test_misc(): d.reset() assert d.sched.getCard() + def test_suspend(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] # suspending @@ -525,7 +549,11 @@ def test_suspend(): d.reset() assert d.sched.getCard() # should cope with rev cards being relearnt - c.due = 0; c.ivl = 100; c.type = 2; c.queue = 2; c.flush() + c.due = 0 + c.ivl = 100 + c.type = 2 + c.queue = 2 + c.flush() d.reset() c = d.sched.getCard() d.sched.answerCard(c, 1) @@ -551,10 +579,11 @@ def test_suspend(): assert c.due == 1 assert c.did == 1 + def test_cram(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] c.ivl = 100 @@ -566,7 +595,7 @@ def test_cram(): c.startTimer() c.flush() d.reset() - assert d.sched.counts() == (0,0,0) + assert d.sched.counts() == (0, 0, 0) cardcopy = copy.copy(c) # create a dynamic deck and refresh it did = d.decks.newDyn("Cram") @@ -575,18 +604,18 @@ def test_cram(): # should appear as new in the deck list assert sorted(d.sched.deckDueList())[0][4] == 1 # and should appear in the counts - assert d.sched.counts() == (1,0,0) + assert d.sched.counts() == (1, 0, 0) # grab it and check estimates c = d.sched.getCard() assert d.sched.answerButtons(c) == 2 assert d.sched.nextIvl(c, 1) == 600 - assert d.sched.nextIvl(c, 2) == 138*60*60*24 + assert d.sched.nextIvl(c, 2) == 138 * 60 * 60 * 24 cram = d.decks.get(did) - cram['delays'] = [1, 10] + cram["delays"] = [1, 10] assert d.sched.answerButtons(c) == 3 assert d.sched.nextIvl(c, 1) == 60 assert d.sched.nextIvl(c, 2) == 600 - assert d.sched.nextIvl(c, 3) == 138*60*60*24 + assert d.sched.nextIvl(c, 3) == 138 * 60 * 60 * 24 d.sched.answerCard(c, 2) # elapsed time was 75 days # factor = 2.5+1.2/2 = 1.85 @@ -595,12 +624,11 @@ def test_cram(): assert c.odue == 138 assert c.queue == 1 # should be logged as a cram rep - assert d.db.scalar( - "select type from revlog order by id desc limit 1") == 3 + assert d.db.scalar("select type from revlog order by id desc limit 1") == 3 # check ivls again assert d.sched.nextIvl(c, 1) == 60 - assert d.sched.nextIvl(c, 2) == 138*60*60*24 - assert d.sched.nextIvl(c, 3) == 138*60*60*24 + assert d.sched.nextIvl(c, 2) == 138 * 60 * 60 * 24 + assert d.sched.nextIvl(c, 3) == 138 * 60 * 60 * 24 # when it graduates, due is updated c = d.sched.getCard() d.sched.answerCard(c, 2) @@ -616,7 +644,7 @@ def test_cram(): # check ivls again - passing should be idempotent assert d.sched.nextIvl(c, 1) == 60 assert d.sched.nextIvl(c, 2) == 600 - assert d.sched.nextIvl(c, 3) == 138*60*60*24 + assert d.sched.nextIvl(c, 3) == 138 * 60 * 60 * 24 d.sched.answerCard(c, 2) assert c.ivl == 138 assert c.odue == 138 @@ -630,20 +658,20 @@ def test_cram(): assert len(d.sched.deckDueList()) == 1 c.load() assert c.ivl == 1 - assert c.due == d.sched.today+1 + assert c.due == d.sched.today + 1 # make it due d.reset() - assert d.sched.counts() == (0,0,0) + assert d.sched.counts() == (0, 0, 0) c.due = -5 c.ivl = 100 c.flush() d.reset() - assert d.sched.counts() == (0,0,1) + assert d.sched.counts() == (0, 0, 1) # cram again did = d.decks.newDyn("Cram") d.sched.rebuildDyn(did) d.reset() - assert d.sched.counts() == (0,0,1) + assert d.sched.counts() == (0, 0, 1) c.load() assert d.sched.answerButtons(c) == 4 # add a sibling so we can test minSpace, etc @@ -661,10 +689,11 @@ def test_cram(): # it should have been moved back to the original deck assert c.did == 1 + def test_cram_rem(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) oldDue = f.cards()[0].due did = d.decks.newDyn("Cram") @@ -681,16 +710,17 @@ def test_cram_rem(): assert c.type == c.queue == 0 assert c.due == oldDue + def test_cram_resched(): # add card d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # cram deck did = d.decks.newDyn("Cram") cram = d.decks.get(did) - cram['resched'] = False + cram["resched"] = False d.sched.rebuildDyn(did) d.reset() # graduate should return it to new @@ -786,22 +816,25 @@ def test_cram_resched(): # d.sched.answerCard(c, 2) # print c.__dict__ + def test_ordcycle(): d = getEmptyCol() # add two more templates and set second active - m = d.models.current(); mm = d.models + m = d.models.current() + mm = d.models t = mm.newTemplate("Reverse") - t['qfmt'] = "{{Back}}" - t['afmt'] = "{{Front}}" + t["qfmt"] = "{{Back}}" + t["afmt"] = "{{Front}}" mm.addTemplate(m, t) t = mm.newTemplate("f2") - t['qfmt'] = "{{Front}}" - t['afmt'] = "{{Back}}" + t["qfmt"] = "{{Front}}" + t["afmt"] = "{{Back}}" mm.addTemplate(m, t) mm.save(m) # create a new note; it should have 3 cards f = d.newNote() - f['Front'] = "1"; f['Back'] = "1" + f["Front"] = "1" + f["Back"] = "1" d.addNote(f) assert d.cardCount() == 3 d.reset() @@ -810,10 +843,12 @@ def test_ordcycle(): assert d.sched.getCard().ord == 1 assert d.sched.getCard().ord == 2 + def test_counts_idx(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) d.reset() assert d.sched.counts() == (1, 0, 0) @@ -832,10 +867,11 @@ def test_counts_idx(): d.sched.answerCard(c, 1) assert d.sched.counts() == (0, 2, 0) + def test_repCounts(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) d.reset() # lrnReps should be accurate on pass/fail @@ -853,7 +889,7 @@ def test_repCounts(): d.sched.answerCard(d.sched.getCard(), 2) assert d.sched.counts() == (0, 0, 0) f = d.newNote() - f['Front'] = "two" + f["Front"] = "two" d.addNote(f) d.reset() # initial pass should be correct too @@ -865,14 +901,14 @@ def test_repCounts(): assert d.sched.counts() == (0, 0, 0) # immediate graduate should work f = d.newNote() - f['Front'] = "three" + f["Front"] = "three" d.addNote(f) d.reset() d.sched.answerCard(d.sched.getCard(), 3) assert d.sched.counts() == (0, 0, 0) # and failing a review should too f = d.newNote() - f['Front'] = "three" + f["Front"] = "three" d.addNote(f) c = f.cards()[0] c.type = 2 @@ -884,12 +920,13 @@ def test_repCounts(): d.sched.answerCard(d.sched.getCard(), 1) assert d.sched.counts() == (0, 1, 0) + def test_timing(): d = getEmptyCol() # add a few review cards, due today for i in range(5): f = d.newNote() - f['Front'] = "num"+str(i) + f["Front"] = "num" + str(i) d.addNote(f) c = f.cards()[0] c.type = 2 @@ -900,7 +937,7 @@ def test_timing(): d.reset() c = d.sched.getCard() # set a a fail delay of 1 second so we don't have to wait - d.sched._cardConf(c)['lapse']['delays'][0] = 1/60.0 + d.sched._cardConf(c)["lapse"]["delays"][0] = 1 / 60.0 d.sched.answerCard(c, 1) # the next card should be another review c = d.sched.getCard() @@ -910,11 +947,12 @@ def test_timing(): c = d.sched.getCard() assert c.queue == 1 + def test_collapse(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) d.reset() # test collapsing @@ -924,16 +962,17 @@ def test_collapse(): d.sched.answerCard(c, 3) assert not d.sched.getCard() + def test_deckDue(): d = getEmptyCol() # add a note with default deck f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # and one that's a child f = d.newNote() - f['Front'] = "two" - default1 = f.model()['did'] = d.decks.id("Default::1") + f["Front"] = "two" + default1 = f.model()["did"] = d.decks.id("Default::1") d.addNote(f) # make it a review card c = f.cards()[0] @@ -942,13 +981,13 @@ def test_deckDue(): c.flush() # add one more with a new deck f = d.newNote() - f['Front'] = "two" - foobar = f.model()['did'] = d.decks.id("foo::bar") + f["Front"] = "two" + foobar = f.model()["did"] = d.decks.id("foo::bar") d.addNote(f) # and one that's a sibling f = d.newNote() - f['Front'] = "three" - foobaz = f.model()['did'] = d.decks.id("foo::baz") + f["Front"] = "three" + foobaz = f.model()["did"] = d.decks.id("foo::baz") d.addNote(f) d.reset() assert len(d.decks.decks) == 5 @@ -970,10 +1009,12 @@ def test_deckDue(): assert tree[0][5][0][2] == 1 assert tree[0][5][0][4] == 0 # code should not fail if a card has an invalid deck - c.did = 12345; c.flush() + c.did = 12345 + c.flush() d.sched.deckDueList() d.sched.deckDueTree() + def test_deckTree(): d = getEmptyCol() d.decks.id("new::b::c") @@ -983,75 +1024,80 @@ def test_deckTree(): names.remove("new") assert "new" not in names + def test_deckFlow(): d = getEmptyCol() # add a note with default deck f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # and one that's a child f = d.newNote() - f['Front'] = "two" - default1 = f.model()['did'] = d.decks.id("Default::2") + f["Front"] = "two" + default1 = f.model()["did"] = d.decks.id("Default::2") d.addNote(f) # and another that's higher up f = d.newNote() - f['Front'] = "three" - default1 = f.model()['did'] = d.decks.id("Default::1") + f["Front"] = "three" + default1 = f.model()["did"] = d.decks.id("Default::1") d.addNote(f) # should get top level one first, then ::1, then ::2 d.reset() - assert d.sched.counts() == (3,0,0) + assert d.sched.counts() == (3, 0, 0) for i in "one", "three", "two": c = d.sched.getCard() - assert c.note()['Front'] == i + assert c.note()["Front"] == i d.sched.answerCard(c, 2) + def test_reorder(): d = getEmptyCol() # add a note with default deck f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) f2 = d.newNote() - f2['Front'] = "two" + f2["Front"] = "two" d.addNote(f2) assert f2.cards()[0].due == 2 - found=False + found = False # 50/50 chance of being reordered for i in range(20): d.sched.randomizeCards(1) if f.cards()[0].due != f.id: - found=True + found = True break assert found d.sched.orderCards(1) assert f.cards()[0].due == 1 # shifting f3 = d.newNote() - f3['Front'] = "three" + f3["Front"] = "three" d.addNote(f3) f4 = d.newNote() - f4['Front'] = "four" + f4["Front"] = "four" d.addNote(f4) assert f.cards()[0].due == 1 assert f2.cards()[0].due == 2 assert f3.cards()[0].due == 3 assert f4.cards()[0].due == 4 - d.sched.sortCards([ - f3.cards()[0].id, f4.cards()[0].id], start=1, shift=True) + d.sched.sortCards([f3.cards()[0].id, f4.cards()[0].id], start=1, shift=True) assert f.cards()[0].due == 3 assert f2.cards()[0].due == 4 assert f3.cards()[0].due == 1 assert f4.cards()[0].due == 2 + def test_forget(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] - c.queue = 2; c.type = 2; c.ivl = 100; c.due = 0 + c.queue = 2 + c.type = 2 + c.ivl = 100 + c.due = 0 c.flush() d.reset() assert d.sched.counts() == (0, 0, 1) @@ -1059,10 +1105,11 @@ def test_forget(): d.reset() assert d.sched.counts() == (1, 0, 0) + def test_resched(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] d.sched.reschedCards([c.id], 0, 0) @@ -1072,14 +1119,15 @@ def test_resched(): assert c.queue == c.type == 2 d.sched.reschedCards([c.id], 1, 1) c.load() - assert c.due == d.sched.today+1 + assert c.due == d.sched.today + 1 assert c.ivl == +1 + def test_norelearn(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] c.type = 2 @@ -1093,13 +1141,15 @@ def test_norelearn(): c.flush() d.reset() d.sched.answerCard(c, 1) - d.sched._cardConf(c)['lapse']['delays'] = [] + d.sched._cardConf(c)["lapse"]["delays"] = [] d.sched.answerCard(c, 1) + def test_failmult(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) c = f.cards()[0] c.type = 2 @@ -1111,7 +1161,7 @@ def test_failmult(): c.lapses = 1 c.startTimer() c.flush() - d.sched._cardConf(c)['lapse']['mult'] = 0.5 + d.sched._cardConf(c)["lapse"]["mult"] = 0.5 c = d.sched.getCard() d.sched.answerCard(c, 1) assert c.ivl == 50 diff --git a/tests/test_schedv2.py b/tests/test_schedv2.py index f3b41fe9b..686bd816f 100644 --- a/tests/test_schedv2.py +++ b/tests/test_schedv2.py @@ -1,34 +1,49 @@ # coding: utf-8 -import time import copy +import time from anki.consts import STARTING_FACTOR -from tests.shared import getEmptyCol -from anki.utils import intTime from anki.hooks import addHook +from anki.utils import intTime +from tests.shared import getEmptyCol + +# Between 2-4AM, shift the time back so test assumptions hold. +lt = time.localtime() +if lt.tm_hour > 2 and lt.tm_hour < 4: + orig_time = time.time + + def adjusted_time(): + return orig_time() - 60 * 60 * 2 + + time.time = adjusted_time + def test_clock(): d = getEmptyCol() - if (d.sched.dayCutoff - intTime()) < 10*60: + if (d.sched.dayCutoff - intTime()) < 10 * 60: raise Exception("Unit tests will fail around the day rollover.") + def checkRevIvl(d, c, targetIvl): min, max = d.sched._fuzzIvlRange(targetIvl) return min <= c.ivl <= max + def test_basics(): d = getEmptyCol() d.reset() assert not d.sched.getCard() + def test_new(): d = getEmptyCol() d.reset() assert d.sched.newCount == 0 # add a note f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) d.reset() assert d.sched.newCount == 1 @@ -66,15 +81,16 @@ def test_new(): # assert qs[n] in c.q() # d.sched.answerCard(c, 2) + def test_newLimits(): d = getEmptyCol() # add some notes g2 = d.decks.id("Default::foo") for i in range(30): f = d.newNote() - f['Front'] = str(i) + f["Front"] = str(i) if i > 4: - f.model()['did'] = g2 + f.model()["did"] = g2 d.addNote(f) # give the child deck a different configuration c2 = d.decks.confId("new conf") @@ -87,33 +103,36 @@ def test_newLimits(): assert c.did == 1 # limit the parent to 10 cards, meaning we get 10 in total conf1 = d.decks.confForDid(1) - conf1['new']['perDay'] = 10 + conf1["new"]["perDay"] = 10 d.reset() assert d.sched.newCount == 10 # if we limit child to 4, we should get 9 conf2 = d.decks.confForDid(g2) - conf2['new']['perDay'] = 4 + conf2["new"]["perDay"] = 4 d.reset() assert d.sched.newCount == 9 + def test_newBoxes(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) d.reset() c = d.sched.getCard() - d.sched._cardConf(c)['new']['delays'] = [1,2,3,4,5] + d.sched._cardConf(c)["new"]["delays"] = [1, 2, 3, 4, 5] d.sched.answerCard(c, 2) # should handle gracefully - d.sched._cardConf(c)['new']['delays'] = [1] + d.sched._cardConf(c)["new"]["delays"] = [1] d.sched.answerCard(c, 2) + def test_learn(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" f = d.addNote(f) # set as a learn card and rebuild queues d.db.execute("update cards set queue=0, type=0") @@ -121,12 +140,12 @@ def test_learn(): # sched.getCard should return it, since it's due in the past c = d.sched.getCard() assert c - d.sched._cardConf(c)['new']['delays'] = [0.5, 3, 10] + d.sched._cardConf(c)["new"]["delays"] = [0.5, 3, 10] # fail it d.sched.answerCard(c, 1) # it should have three reps left to graduation - assert c.left%1000 == 3 - assert c.left//1000 == 3 + assert c.left % 1000 == 3 + assert c.left // 1000 == 3 # it should by due in 30 seconds t = round(c.due - time.time()) assert t >= 25 and t <= 40 @@ -134,9 +153,9 @@ def test_learn(): d.sched.answerCard(c, 3) # it should by due in 3 minutes dueIn = c.due - time.time() - assert 179 <= dueIn <= 180*1.25 - assert c.left%1000 == 2 - assert c.left//1000 == 2 + assert 179 <= dueIn <= 180 * 1.25 + assert c.left % 1000 == 2 + assert c.left // 1000 == 2 # check log is accurate log = d.db.first("select * from revlog order by id desc") assert log[3] == 3 @@ -146,9 +165,9 @@ def test_learn(): d.sched.answerCard(c, 3) # it should by due in 10 minutes dueIn = c.due - time.time() - assert 599 <= dueIn <= 600*1.25 - assert c.left%1000 == 1 - assert c.left//1000 == 1 + assert 599 <= dueIn <= 600 * 1.25 + assert c.left % 1000 == 1 + assert c.left // 1000 == 1 # the next pass should graduate the card assert c.queue == 1 assert c.type == 1 @@ -156,7 +175,7 @@ def test_learn(): assert c.queue == 2 assert c.type == 2 # should be due tomorrow, with an interval of 1 - assert c.due == d.sched.today+1 + assert c.due == d.sched.today + 1 assert c.ivl == 1 # or normal removal c.type = 0 @@ -168,10 +187,11 @@ def test_learn(): # revlog should have been updated each time assert d.db.scalar("select count() from revlog where type = 0") == 5 + def test_relearn(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] c.ivl = 100 @@ -193,10 +213,11 @@ def test_relearn(): assert c.ivl == 2 assert c.due == d.sched.today + c.ivl + def test_relearn_no_steps(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] c.ivl = 100 @@ -205,7 +226,7 @@ def test_relearn_no_steps(): c.flush() conf = d.decks.confForDid(1) - conf['lapse']['delays'] = [] + conf["lapse"]["delays"] = [] d.decks.save(conf) # fail the card @@ -214,14 +235,15 @@ def test_relearn_no_steps(): d.sched.answerCard(c, 1) assert c.type == c.queue == 2 + def test_learn_collapsed(): d = getEmptyCol() # add 2 notes f = d.newNote() - f['Front'] = "1" + f["Front"] = "1" f = d.addNote(f) f = d.newNote() - f['Front'] = "2" + f["Front"] = "2" f = d.addNote(f) # set as a learn card and rebuild queues d.db.execute("update cards set queue=0, type=0") @@ -240,27 +262,28 @@ def test_learn_collapsed(): c = d.sched.getCard() assert not c.q().endswith("2") + def test_learn_day(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" f = d.addNote(f) d.sched.reset() c = d.sched.getCard() - d.sched._cardConf(c)['new']['delays'] = [1, 10, 1440, 2880] + d.sched._cardConf(c)["new"]["delays"] = [1, 10, 1440, 2880] # pass it d.sched.answerCard(c, 3) # two reps to graduate, 1 more today - assert c.left%1000 == 3 - assert c.left//1000 == 1 + assert c.left % 1000 == 3 + assert c.left // 1000 == 1 assert d.sched.counts() == (0, 1, 0) c = d.sched.getCard() ni = d.sched.nextIvl assert ni(c, 3) == 86400 # answering it will place it in queue 3 d.sched.answerCard(c, 3) - assert c.due == d.sched.today+1 + assert c.due == d.sched.today + 1 assert c.queue == 3 assert not d.sched.getCard() # for testing, move it back a day @@ -270,7 +293,7 @@ def test_learn_day(): assert d.sched.counts() == (0, 1, 0) c = d.sched.getCard() # nextIvl should work - assert ni(c, 3) == 86400*2 + assert ni(c, 3) == 86400 * 2 # if we fail it, it should be back in the correct queue d.sched.answerCard(c, 1) assert c.queue == 1 @@ -292,17 +315,19 @@ def test_learn_day(): c.flush() d.reset() assert d.sched.counts() == (0, 0, 1) - d.sched._cardConf(c)['lapse']['delays'] = [1440] + d.sched._cardConf(c)["lapse"]["delays"] = [1440] c = d.sched.getCard() d.sched.answerCard(c, 1) assert c.queue == 3 assert d.sched.counts() == (0, 0, 0) + def test_reviews(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) # set the card up as a review card, due 8 days ago c = f.cards()[0] @@ -359,8 +384,10 @@ def test_reviews(): c.flush() # steup hook hooked = [] + def onLeech(card): hooked.append(1) + addHook("leech", onLeech) d.sched.answerCard(c, 1) assert hooked @@ -368,6 +395,7 @@ def test_reviews(): c.load() assert c.queue == -1 + def test_review_limits(): d = getEmptyCol() @@ -377,21 +405,22 @@ def test_review_limits(): pconf = d.decks.getConf(d.decks.confId("parentConf")) cconf = d.decks.getConf(d.decks.confId("childConf")) - pconf['rev']['perDay'] = 5 + pconf["rev"]["perDay"] = 5 d.decks.updateConf(pconf) - d.decks.setConf(parent, pconf['id']) - cconf['rev']['perDay'] = 10 + d.decks.setConf(parent, pconf["id"]) + cconf["rev"]["perDay"] = 10 d.decks.updateConf(cconf) - d.decks.setConf(child, cconf['id']) + d.decks.setConf(child, cconf["id"]) m = d.models.current() - m['did'] = child['id'] + m["did"] = child["id"] d.models.save(m, updateReqs=False) # add some cards for i in range(20): f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) # make them reviews @@ -402,11 +431,11 @@ def test_review_limits(): tree = d.sched.deckDueTree() # (('Default', 1, 0, 0, 0, ()), ('parent', 1514457677462, 5, 0, 0, (('child', 1514457677463, 5, 0, 0, ()),))) - assert tree[1][2] == 5 # parent - assert tree[1][5][0][2] == 5 # child + assert tree[1][2] == 5 # parent + assert tree[1][5][0][2] == 5 # child # .counts() should match - d.decks.select(child['id']) + d.decks.select(child["id"]) d.sched.reset() assert d.sched.counts() == (0, 0, 5) @@ -416,24 +445,25 @@ def test_review_limits(): assert d.sched.counts() == (0, 0, 4) tree = d.sched.deckDueTree() - assert tree[1][2] == 4 # parent - assert tree[1][5][0][2] == 4 # child + assert tree[1][2] == 4 # parent + assert tree[1][5][0][2] == 4 # child # switch limits - d.decks.setConf(parent, cconf['id']) - d.decks.setConf(child, pconf['id']) - d.decks.select(parent['id']) + d.decks.setConf(parent, cconf["id"]) + d.decks.setConf(child, pconf["id"]) + d.decks.select(parent["id"]) d.sched.reset() # child limits do not affect the parent tree = d.sched.deckDueTree() - assert tree[1][2] == 9 # parent - assert tree[1][5][0][2] == 4 # child + assert tree[1][2] == 9 # parent + assert tree[1][5][0][2] == 4 # child + def test_button_spacing(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # 1 day ivl review card due now c = f.cards()[0] @@ -452,16 +482,17 @@ def test_button_spacing(): # if hard factor is <= 1, then hard may not increase conf = d.decks.confForDid(1) - conf['rev']['hardFactor'] = 1 + conf["rev"]["hardFactor"] = 1 assert ni(c, 2) == "1 day" + def test_overdue_lapse(): # disabled in commit 3069729776990980f34c25be66410e947e9d51a2 return d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # simulate a review that was lapsed and is now due for its normal review c = f.cards()[0] @@ -490,13 +521,15 @@ def test_overdue_lapse(): d.sched.reset() assert d.sched.counts() == (0, 0, 1) + def test_finished(): d = getEmptyCol() # nothing due assert "Congratulations" in d.sched.finishedMsg() assert "limit" not in d.sched.finishedMsg() f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) # have a new card assert "new cards available" in d.sched.finishedMsg() @@ -509,47 +542,49 @@ def test_finished(): assert "Congratulations" in d.sched.finishedMsg() assert "limit" not in d.sched.finishedMsg() + def test_nextIvl(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) d.reset() conf = d.decks.confForDid(1) - conf['new']['delays'] = [0.5, 3, 10] - conf['lapse']['delays'] = [1, 5, 9] + conf["new"]["delays"] = [0.5, 3, 10] + conf["lapse"]["delays"] = [1, 5, 9] c = d.sched.getCard() # new cards ################################################## ni = d.sched.nextIvl assert ni(c, 1) == 30 - assert ni(c, 2) == (30+180)//2 + assert ni(c, 2) == (30 + 180) // 2 assert ni(c, 3) == 180 - assert ni(c, 4) == 4*86400 + assert ni(c, 4) == 4 * 86400 d.sched.answerCard(c, 1) # cards in learning ################################################## assert ni(c, 1) == 30 - assert ni(c, 2) == (30+180)//2 + assert ni(c, 2) == (30 + 180) // 2 assert ni(c, 3) == 180 - assert ni(c, 4) == 4*86400 + assert ni(c, 4) == 4 * 86400 d.sched.answerCard(c, 3) assert ni(c, 1) == 30 - assert ni(c, 2) == (180+600)//2 + assert ni(c, 2) == (180 + 600) // 2 assert ni(c, 3) == 600 - assert ni(c, 4) == 4*86400 + assert ni(c, 4) == 4 * 86400 d.sched.answerCard(c, 3) # normal graduation is tomorrow - assert ni(c, 3) == 1*86400 - assert ni(c, 4) == 4*86400 + assert ni(c, 3) == 1 * 86400 + assert ni(c, 4) == 4 * 86400 # lapsed cards ################################################## c.type = 2 c.ivl = 100 c.factor = STARTING_FACTOR assert ni(c, 1) == 60 - assert ni(c, 3) == 100*86400 - assert ni(c, 4) == 101*86400 + assert ni(c, 3) == 100 * 86400 + assert ni(c, 4) == 101 * 86400 # review cards ################################################## c.queue = 2 @@ -558,8 +593,8 @@ def test_nextIvl(): # failing it should put it at 60s assert ni(c, 1) == 60 # or 1 day if relearn is false - d.sched._cardConf(c)['lapse']['delays']=[] - assert ni(c, 1) == 1*86400 + d.sched._cardConf(c)["lapse"]["delays"] = [] + assert ni(c, 1) == 1 * 86400 # (* 100 1.2 86400)10368000.0 assert ni(c, 2) == 10368000 # (* 100 2.5 86400)21600000.0 @@ -568,14 +603,15 @@ def test_nextIvl(): assert ni(c, 4) == 28080000 assert d.sched.nextIvlStr(c, 4) == "10.8 months" + def test_bury(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] f = d.newNote() - f['Front'] = "two" + f["Front"] = "two" d.addNote(f) c2 = f.cards()[0] # burying @@ -590,11 +626,14 @@ def test_bury(): assert not d.sched.getCard() d.sched.unburyCardsForDeck(type="manual") - c.load(); assert c.queue == 0 - c2.load(); assert c2.queue == -2 + c.load() + assert c.queue == 0 + c2.load() + assert c2.queue == -2 d.sched.unburyCardsForDeck(type="siblings") - c2.load(); assert c2.queue == 0 + c2.load() + assert c2.queue == 0 d.sched.buryCards([c.id, c2.id]) d.sched.unburyCardsForDeck(type="all") @@ -603,10 +642,11 @@ def test_bury(): assert d.sched.counts() == (2, 0, 0) + def test_suspend(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] # suspending @@ -620,7 +660,11 @@ def test_suspend(): d.reset() assert d.sched.getCard() # should cope with rev cards being relearnt - c.due = 0; c.ivl = 100; c.type = 2; c.queue = 2; c.flush() + c.due = 0 + c.ivl = 100 + c.type = 2 + c.queue = 2 + c.flush() d.reset() c = d.sched.getCard() d.sched.answerCard(c, 1) @@ -648,10 +692,11 @@ def test_suspend(): assert c.did != 1 assert c.odue == 1 + def test_filt_reviewing_early_normal(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] c.ivl = 100 @@ -663,7 +708,7 @@ def test_filt_reviewing_early_normal(): c.startTimer() c.flush() d.reset() - assert d.sched.counts() == (0,0,0) + assert d.sched.counts() == (0, 0, 0) # create a dynamic deck and refresh it did = d.decks.newDyn("Cram") d.sched.rebuildDyn(did) @@ -671,14 +716,14 @@ def test_filt_reviewing_early_normal(): # should appear as normal in the deck list assert sorted(d.sched.deckDueList())[0][2] == 1 # and should appear in the counts - assert d.sched.counts() == (0,0,1) + assert d.sched.counts() == (0, 0, 1) # grab it and check estimates c = d.sched.getCard() assert d.sched.answerButtons(c) == 4 assert d.sched.nextIvl(c, 1) == 600 - assert d.sched.nextIvl(c, 2) == int(75*1.2)*86400 - assert d.sched.nextIvl(c, 3) == int(75*2.5)*86400 - assert d.sched.nextIvl(c, 4) == int(75*2.5*1.15)*86400 + assert d.sched.nextIvl(c, 2) == int(75 * 1.2) * 86400 + assert d.sched.nextIvl(c, 3) == int(75 * 2.5) * 86400 + assert d.sched.nextIvl(c, 4) == int(75 * 2.5 * 1.15) * 86400 # answer 'good' d.sched.answerCard(c, 3) @@ -688,8 +733,7 @@ def test_filt_reviewing_early_normal(): # should not be in learning assert c.queue == 2 # should be logged as a cram rep - assert d.db.scalar( - "select type from revlog order by id desc limit 1") == 3 + assert d.db.scalar("select type from revlog order by id desc limit 1") == 3 # due in 75 days, so it's been waiting 25 days c.ivl = 100 @@ -699,20 +743,21 @@ def test_filt_reviewing_early_normal(): d.reset() c = d.sched.getCard() - assert d.sched.nextIvl(c, 2) == 60*86400 - assert d.sched.nextIvl(c, 3) == 100*86400 - assert d.sched.nextIvl(c, 4) == 114*86400 + assert d.sched.nextIvl(c, 2) == 60 * 86400 + assert d.sched.nextIvl(c, 3) == 100 * 86400 + assert d.sched.nextIvl(c, 4) == 114 * 86400 + def test_filt_keep_lrn_state(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # fail the card outside filtered deck c = d.sched.getCard() - d.sched._cardConf(c)['new']['delays'] = [1, 10, 61] + d.sched._cardConf(c)["new"]["delays"] = [1, 10, 61] d.decks.save() d.sched.answerCard(c, 1) @@ -736,30 +781,31 @@ def test_filt_keep_lrn_state(): # should be able to advance learning steps d.sched.answerCard(c, 3) # should be due at least an hour in the future - assert c.due - intTime() > 60*60 + assert c.due - intTime() > 60 * 60 # emptying the deck preserves learning state d.sched.emptyDyn(did) c.load() assert c.type == c.queue == 1 assert c.left == 1001 - assert c.due - intTime() > 60*60 + assert c.due - intTime() > 60 * 60 + def test_preview(): # add cards d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] orig = copy.copy(c) f2 = d.newNote() - f2['Front'] = "two" + f2["Front"] = "two" d.addNote(f2) # cram deck did = d.decks.newDyn("Cram") cram = d.decks.get(did) - cram['resched'] = False + cram["resched"] = False d.sched.rebuildDyn(did) d.reset() # grab the first card @@ -793,22 +839,25 @@ def test_preview(): assert c.reps == 0 assert c.type == 0 + def test_ordcycle(): d = getEmptyCol() # add two more templates and set second active - m = d.models.current(); mm = d.models + m = d.models.current() + mm = d.models t = mm.newTemplate("Reverse") - t['qfmt'] = "{{Back}}" - t['afmt'] = "{{Front}}" + t["qfmt"] = "{{Back}}" + t["afmt"] = "{{Front}}" mm.addTemplate(m, t) t = mm.newTemplate("f2") - t['qfmt'] = "{{Front}}" - t['afmt'] = "{{Back}}" + t["qfmt"] = "{{Front}}" + t["afmt"] = "{{Back}}" mm.addTemplate(m, t) mm.save(m) # create a new note; it should have 3 cards f = d.newNote() - f['Front'] = "1"; f['Back'] = "1" + f["Front"] = "1" + f["Back"] = "1" d.addNote(f) assert d.cardCount() == 3 d.reset() @@ -817,10 +866,12 @@ def test_ordcycle(): assert d.sched.getCard().ord == 1 assert d.sched.getCard().ord == 2 + def test_counts_idx(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) d.reset() assert d.sched.counts() == (1, 0, 0) @@ -839,10 +890,11 @@ def test_counts_idx(): d.sched.answerCard(c, 1) assert d.sched.counts() == (0, 1, 0) + def test_repCounts(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) d.reset() # lrnReps should be accurate on pass/fail @@ -860,7 +912,7 @@ def test_repCounts(): d.sched.answerCard(d.sched.getCard(), 3) assert d.sched.counts() == (0, 0, 0) f = d.newNote() - f['Front'] = "two" + f["Front"] = "two" d.addNote(f) d.reset() # initial pass should be correct too @@ -872,14 +924,14 @@ def test_repCounts(): assert d.sched.counts() == (0, 0, 0) # immediate graduate should work f = d.newNote() - f['Front'] = "three" + f["Front"] = "three" d.addNote(f) d.reset() d.sched.answerCard(d.sched.getCard(), 4) assert d.sched.counts() == (0, 0, 0) # and failing a review should too f = d.newNote() - f['Front'] = "three" + f["Front"] = "three" d.addNote(f) c = f.cards()[0] c.type = 2 @@ -891,12 +943,13 @@ def test_repCounts(): d.sched.answerCard(d.sched.getCard(), 1) assert d.sched.counts() == (0, 1, 0) + def test_timing(): d = getEmptyCol() # add a few review cards, due today for i in range(5): f = d.newNote() - f['Front'] = "num"+str(i) + f["Front"] = "num" + str(i) d.addNote(f) c = f.cards()[0] c.type = 2 @@ -917,11 +970,12 @@ def test_timing(): c = d.sched.getCard() assert c.queue == 1 + def test_collapse(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) d.reset() # test collapsing @@ -931,16 +985,17 @@ def test_collapse(): d.sched.answerCard(c, 4) assert not d.sched.getCard() + def test_deckDue(): d = getEmptyCol() # add a note with default deck f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # and one that's a child f = d.newNote() - f['Front'] = "two" - default1 = f.model()['did'] = d.decks.id("Default::1") + f["Front"] = "two" + default1 = f.model()["did"] = d.decks.id("Default::1") d.addNote(f) # make it a review card c = f.cards()[0] @@ -949,13 +1004,13 @@ def test_deckDue(): c.flush() # add one more with a new deck f = d.newNote() - f['Front'] = "two" - foobar = f.model()['did'] = d.decks.id("foo::bar") + f["Front"] = "two" + foobar = f.model()["did"] = d.decks.id("foo::bar") d.addNote(f) # and one that's a sibling f = d.newNote() - f['Front'] = "three" - foobaz = f.model()['did'] = d.decks.id("foo::baz") + f["Front"] = "three" + foobaz = f.model()["did"] = d.decks.id("foo::baz") d.addNote(f) d.reset() assert len(d.decks.decks) == 5 @@ -977,10 +1032,12 @@ def test_deckDue(): assert tree[0][5][0][2] == 1 assert tree[0][5][0][4] == 0 # code should not fail if a card has an invalid deck - c.did = 12345; c.flush() + c.did = 12345 + c.flush() d.sched.deckDueList() d.sched.deckDueTree() + def test_deckTree(): d = getEmptyCol() d.decks.id("new::b::c") @@ -990,75 +1047,80 @@ def test_deckTree(): names.remove("new") assert "new" not in names + def test_deckFlow(): d = getEmptyCol() # add a note with default deck f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # and one that's a child f = d.newNote() - f['Front'] = "two" - default1 = f.model()['did'] = d.decks.id("Default::2") + f["Front"] = "two" + default1 = f.model()["did"] = d.decks.id("Default::2") d.addNote(f) # and another that's higher up f = d.newNote() - f['Front'] = "three" - default1 = f.model()['did'] = d.decks.id("Default::1") + f["Front"] = "three" + default1 = f.model()["did"] = d.decks.id("Default::1") d.addNote(f) # should get top level one first, then ::1, then ::2 d.reset() - assert d.sched.counts() == (3,0,0) + assert d.sched.counts() == (3, 0, 0) for i in "one", "three", "two": c = d.sched.getCard() - assert c.note()['Front'] == i + assert c.note()["Front"] == i d.sched.answerCard(c, 3) + def test_reorder(): d = getEmptyCol() # add a note with default deck f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) f2 = d.newNote() - f2['Front'] = "two" + f2["Front"] = "two" d.addNote(f2) assert f2.cards()[0].due == 2 - found=False + found = False # 50/50 chance of being reordered for i in range(20): d.sched.randomizeCards(1) if f.cards()[0].due != f.id: - found=True + found = True break assert found d.sched.orderCards(1) assert f.cards()[0].due == 1 # shifting f3 = d.newNote() - f3['Front'] = "three" + f3["Front"] = "three" d.addNote(f3) f4 = d.newNote() - f4['Front'] = "four" + f4["Front"] = "four" d.addNote(f4) assert f.cards()[0].due == 1 assert f2.cards()[0].due == 2 assert f3.cards()[0].due == 3 assert f4.cards()[0].due == 4 - d.sched.sortCards([ - f3.cards()[0].id, f4.cards()[0].id], start=1, shift=True) + d.sched.sortCards([f3.cards()[0].id, f4.cards()[0].id], start=1, shift=True) assert f.cards()[0].due == 3 assert f2.cards()[0].due == 4 assert f3.cards()[0].due == 1 assert f4.cards()[0].due == 2 + def test_forget(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] - c.queue = 2; c.type = 2; c.ivl = 100; c.due = 0 + c.queue = 2 + c.type = 2 + c.ivl = 100 + c.due = 0 c.flush() d.reset() assert d.sched.counts() == (0, 0, 1) @@ -1066,10 +1128,11 @@ def test_forget(): d.reset() assert d.sched.counts() == (1, 0, 0) + def test_resched(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] d.sched.reschedCards([c.id], 0, 0) @@ -1079,14 +1142,15 @@ def test_resched(): assert c.queue == c.type == 2 d.sched.reschedCards([c.id], 1, 1) c.load() - assert c.due == d.sched.today+1 + assert c.due == d.sched.today + 1 assert c.ivl == +1 + def test_norelearn(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] c.type = 2 @@ -1100,13 +1164,15 @@ def test_norelearn(): c.flush() d.reset() d.sched.answerCard(c, 1) - d.sched._cardConf(c)['lapse']['delays'] = [] + d.sched._cardConf(c)["lapse"]["delays"] = [] d.sched.answerCard(c, 1) + def test_failmult(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) c = f.cards()[0] c.type = 2 @@ -1118,19 +1184,20 @@ def test_failmult(): c.lapses = 1 c.startTimer() c.flush() - d.sched._cardConf(c)['lapse']['mult'] = 0.5 + d.sched._cardConf(c)["lapse"]["mult"] = 0.5 c = d.sched.getCard() d.sched.answerCard(c, 1) assert c.ivl == 50 d.sched.answerCard(c, 1) assert c.ivl == 25 + def test_moveVersions(): col = getEmptyCol() col.changeSchedulerVer(1) n = col.newNote() - n['Front'] = "one" + n["Front"] = "one" col.addNote(n) # make it a learning card @@ -1168,8 +1235,10 @@ def test_moveVersions(): col.changeSchedulerVer(2) # card with 100 day interval, answering again col.sched.reschedCards([c.id], 100, 100) - c.load(); c.due = 0; c.flush() - col.sched._cardConf(c)['lapse']['mult'] = 0.5 + c.load() + c.due = 0 + c.flush() + col.sched._cardConf(c)["lapse"]["mult"] = 0.5 col.sched.reset() c = col.sched.getCard() col.sched.answerCard(c, 1) @@ -1178,6 +1247,7 @@ def test_moveVersions(): c.load() assert c.due == 50 + # cards with a due date earlier than the collection should retain # their due date when removed def test_negativeDueFilter(): @@ -1185,7 +1255,8 @@ def test_negativeDueFilter(): # card due prior to collection date f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) c = f.cards()[0] c.due = -5 @@ -1201,4 +1272,3 @@ def test_negativeDueFilter(): c.load() assert c.due == -5 - diff --git a/tests/test_stats.py b/tests/test_stats.py index b7212d1e5..7de3dc831 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,12 +1,15 @@ # coding: utf-8 -import os -from tests.shared import getEmptyCol +import os +import tempfile + +from tests.shared import getEmptyCol + def test_stats(): d = getEmptyCol() f = d.newNote() - f['Front'] = "foo" + f["Front"] = "foo" d.addNote(f) c = f.cards()[0] # card stats @@ -17,15 +20,20 @@ def test_stats(): d.sched.answerCard(c, 2) assert d.cardStats(c) + def test_graphs_empty(): d = getEmptyCol() assert d.stats().report() + def test_graphs(): from anki import Collection as aopen - d = aopen(os.path.expanduser("~/test.anki2")) + + dir = tempfile.gettempdir() + + d = aopen(os.path.join(dir, "test.anki2")) g = d.stats() rep = g.report() - with open(os.path.expanduser("~/test.html"), "w") as f: + with open(os.path.join(dir, "test.html"), "w") as f: f.write(rep) return diff --git a/tests/test_template.py b/tests/test_template.py index 7132c1240..5a61e11f8 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -2,15 +2,18 @@ from anki.template import Template def test_remove_formatting_from_mathjax(): - t = Template('') - assert t._removeFormattingFromMathjax(r'\(2^{{c3::2}}\)', 3) == r'\(2^{{C3::2}}\)' + t = Template("") + assert t._removeFormattingFromMathjax(r"\(2^{{c3::2}}\)", 3) == r"\(2^{{C3::2}}\)" - txt = (r'{{c1::ok}} \(2^2\) {{c2::not ok}} \(2^{{c3::2}}\) \(x^3\) ' - r'{{c4::blah}} {{c5::text with \(x^2\) jax}}') + txt = ( + r"{{c1::ok}} \(2^2\) {{c2::not ok}} \(2^{{c3::2}}\) \(x^3\) " + r"{{c4::blah}} {{c5::text with \(x^2\) jax}}" + ) # Cloze 2 is not in MathJax, so it should not get protected against # formatting. assert t._removeFormattingFromMathjax(txt, 2) == txt - txt = r'\(a\) {{c1::b}} \[ {{c1::c}} \]' + txt = r"\(a\) {{c1::b}} \[ {{c1::c}} \]" assert t._removeFormattingFromMathjax(txt, 1) == ( - r'\(a\) {{c1::b}} \[ {{C1::c}} \]') + r"\(a\) {{c1::b}} \[ {{C1::c}} \]" + ) diff --git a/tests/test_undo.py b/tests/test_undo.py index 457112987..4b310e171 100644 --- a/tests/test_undo.py +++ b/tests/test_undo.py @@ -1,8 +1,10 @@ # coding: utf-8 import time -from tests.shared import getEmptyCol + from anki.consts import * +from tests.shared import getEmptyCol + def test_op(): d = getEmptyCol() @@ -10,7 +12,7 @@ def test_op(): assert not d.undoName() # let's adjust a study option d.save("studyopts") - d.conf['abc'] = 5 + d.conf["abc"] = 5 # it should be listed as undoable assert d.undoName() == "studyopts" # with about 5 minutes until it's clobbered @@ -18,7 +20,7 @@ def test_op(): # undoing should restore the old value d.undo() assert not d.undoName() - assert 'abc' not in d.conf + assert "abc" not in d.conf # an (auto)save will clear the undo d.save("foo") assert d.undoName() == "foo" @@ -27,7 +29,7 @@ def test_op(): # and a review will, too d.save("add") f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) d.reset() assert d.undoName() == "add" @@ -35,11 +37,12 @@ def test_op(): d.sched.answerCard(c, 2) assert d.undoName() == "Review" + def test_review(): d = getEmptyCol() - d.conf['counts'] = COUNT_REMAINING + d.conf["counts"] = COUNT_REMAINING f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) d.reset() assert not d.undoName() @@ -62,7 +65,7 @@ def test_review(): assert not d.undoName() # we should be able to undo multiple answers too f = d.newNote() - f['Front'] = "two" + f["Front"] = "two" d.addNote(f) d.reset() assert d.sched.counts() == (2, 0, 0) @@ -85,5 +88,3 @@ def test_review(): assert d.undoName() == "foo" d.undo() assert not d.undoName() - - diff --git a/tests/test_utils.py b/tests/test_utils.py index 96d787a83..8ac52d4f1 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,6 +2,7 @@ from anki.utils import fmtTimeSpan + def test_fmtTimeSpan(): assert fmtTimeSpan(5) == "5 seconds" assert fmtTimeSpan(5, inTime=True) == "in 5 seconds" diff --git a/tools/tests.sh b/tools/tests.sh index b6fe8f12c..40c8f8b27 100755 --- a/tools/tests.sh +++ b/tools/tests.sh @@ -10,11 +10,7 @@ set -e BIN="$(cd "`dirname "$0"`"; pwd)" export PYTHONPATH=${BIN}/..:${PYTHONPATH} -# favour nosetests3 if available -nose=nosetests -if which nosetests3 >/dev/null 2>&1; then - nose=nosetests3 -fi +nose="python -m nose2 --plugin=nose2.plugins.mp -N 16" dir=. @@ -24,7 +20,4 @@ else lim="tests.test_$1" fi -if [ x$coverage != x ]; then - args="--with-coverage" -fi -(cd $dir && $nose -s --processes=16 --process-timeout=300 $lim $args --cover-package=anki) +(cd $dir && $nose $lim $args) diff --git a/ts/package-lock.json b/ts/package-lock.json index 3256a1878..0ced64501 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -10,7 +10,7 @@ "integrity": "sha512-Lz4BAJihoFw5nRzKvg4nawXPzutkv7wmfQ5121avptaSIXlDNJCUuxZxX/G+9EVidZGuO0UBlk+YjKbwRKJigg==", "dev": true, "requires": { - "@types/sizzle": "*" + "@types/sizzle": "2.3.2" } }, "@types/jqueryui": { @@ -19,7 +19,7 @@ "integrity": "sha512-bHE7BiG+5Sviy/eA9Npz5HHF3hv40XjaEbpYtSJPaNwuyxhSJ0qWlE8C5DgNMfobVOZ2aSTrM1iGDCGmvlbxOg==", "dev": true, "requires": { - "@types/jquery": "*" + "@types/jquery": "3.3.31" } }, "@types/mathjax": {