anki/pylib/tests/test_find.py

313 lines
11 KiB
Python
Raw Normal View History

2021-04-13 10:45:05 +02:00
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# coding: utf-8
import pytest
from anki.browser import BrowserConfig
2020-02-02 10:40:30 +01:00
from anki.consts import *
2020-03-22 05:41:01 +01:00
from tests.shared import getEmptyCol, isNearCutoff
2019-12-25 05:18:34 +01:00
class DummyCollection:
def weakref(self):
return None
2021-06-27 07:12:22 +02:00
def test_find_cards():
col = getEmptyCol()
note = col.newNote()
note["Front"] = "dog"
note["Back"] = "cat"
note.tags.append("monkey animal_1 * %")
col.addNote(note)
2020-07-17 17:30:29 +02:00
n1id = note.id
firstCardId = note.cards()[0].id
note = col.newNote()
note["Front"] = "goats are fun"
note["Back"] = "sheep"
note.tags.append("sheep goat horse animal11")
col.addNote(note)
2020-07-17 17:30:29 +02:00
n2id = note.id
note = col.newNote()
note["Front"] = "cat"
note["Back"] = "sheep"
col.addNote(note)
catCard = note.cards()[0]
m = col.models.current()
m = col.models.copy(m)
mm = col.models
2021-06-27 05:49:58 +02:00
t = mm.new_template("Reverse")
2019-12-25 05:18:34 +01:00
t["qfmt"] = "{{Back}}"
t["afmt"] = "{{Front}}"
2021-06-27 05:49:58 +02:00
mm.add_template(m, t)
mm.save(m)
note = col.newNote()
note["Front"] = "test"
note["Back"] = "foo bar"
col.addNote(note)
latestCardIds = [c.id for c in note.cards()]
# tag searches
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("tag:*")) == 5
assert len(col.find_cards("tag:\\*")) == 1
assert len(col.find_cards("tag:%")) == 1
assert len(col.find_cards("tag:sheep_goat")) == 0
assert len(col.find_cards('"tag:sheep goat"')) == 0
assert len(col.find_cards('"tag:* *"')) == 0
assert len(col.find_cards("tag:animal_1")) == 2
assert len(col.find_cards("tag:animal\\_1")) == 1
assert not col.find_cards("tag:donkey")
assert len(col.find_cards("tag:sheep")) == 1
assert len(col.find_cards("tag:sheep tag:goat")) == 1
assert len(col.find_cards("tag:sheep tag:monkey")) == 0
assert len(col.find_cards("tag:monkey")) == 1
assert len(col.find_cards("tag:sheep -tag:monkey")) == 1
assert len(col.find_cards("-tag:sheep")) == 4
2021-03-05 11:47:51 +01:00
col.tags.bulk_add(col.db.list("select id from notes"), "foo bar")
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("tag:foo")) == len(col.find_cards("tag:bar")) == 5
PEP8 for rest of pylib (#1451) * PEP8 dbproxy.py * PEP8 errors.py * PEP8 httpclient.py * PEP8 lang.py * PEP8 latex.py * Add decorator to deprectate key words * Make replacement for deprecated attribute optional * Use new helper `_print_replacement_warning()` * PEP8 media.py * PEP8 rsbackend.py * PEP8 sound.py * PEP8 stdmodels.py * PEP8 storage.py * PEP8 sync.py * PEP8 tags.py * PEP8 template.py * PEP8 types.py * Fix DeprecatedNamesMixinForModule The class methods need to be overridden with instance methods, so every module has its own dicts. * Use `# pylint: disable=invalid-name` instead of id * PEP8 utils.py * Only decorate `__getattr__` with `@no_type_check` * Fix mypy issue with snakecase Importing it from `anki._vendor` raises attribute errors. * Format * Remove inheritance of DeprecatedNamesMixin There's almost no shared code now and overriding classmethods with instance methods raises mypy issues. * Fix traceback frames of deprecation warnings * remove fn/TimedLog (dae) Neither Anki nor add-ons appear to have been using it * fix some issues with stringcase use (dae) - the wheel was depending on the PyPI version instead of our vendored version - _vendor:stringcase should not have been listed in the anki py_library. We already include the sources in py_srcs, and need to refer to them directly. By listing _vendor:stringcase as well, we were making a top-level stringcase library available, which would have only worked for distributing because the wheel definition was also incorrect. - mypy errors are what caused me to mistakenly add the above - they were because the type: ignore at the top of stringcase.py was causing mypy to completely ignore the file, so it was not aware of any attributes it contained.
2021-10-25 06:50:13 +02:00
col.tags.bulk_remove(col.db.list("select id from notes"), "foo")
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("tag:foo")) == 0
assert len(col.find_cards("tag:bar")) == 5
# text searches
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("cat")) == 2
assert len(col.find_cards("cat -dog")) == 1
assert len(col.find_cards("cat -dog")) == 1
assert len(col.find_cards("are goats")) == 1
assert len(col.find_cards('"are goats"')) == 0
assert len(col.find_cards('"goats are"')) == 1
# card states
c = note.cards()[0]
2020-02-02 10:40:30 +01:00
c.queue = c.type = CARD_TYPE_REV
2021-06-27 07:12:22 +02:00
assert col.find_cards("is:review") == []
c.flush()
2021-06-27 07:12:22 +02:00
assert col.find_cards("is:review") == [c.id]
assert col.find_cards("is:due") == []
2019-12-25 05:18:34 +01:00
c.due = 0
2020-02-02 10:40:30 +01:00
c.queue = QUEUE_TYPE_REV
c.flush()
2021-06-27 07:12:22 +02:00
assert col.find_cards("is:due") == [c.id]
assert len(col.find_cards("-is:due")) == 4
2020-07-19 05:27:31 +02:00
c.queue = QUEUE_TYPE_SUSPENDED
# ensure this card gets a later mod time
c.flush()
col.db.execute("update cards set mod = mod + 1 where id = ?", c.id)
2021-06-27 07:12:22 +02:00
assert col.find_cards("is:suspended") == [c.id]
# nids
2021-06-27 07:12:22 +02:00
assert col.find_cards("nid:54321") == []
assert len(col.find_cards(f"nid:{note.id}")) == 2
assert len(col.find_cards(f"nid:{n1id},{n2id}")) == 2
# templates
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("card:foo")) == 0
assert len(col.find_cards('"card:card 1"')) == 4
assert len(col.find_cards("card:reverse")) == 1
assert len(col.find_cards("card:1")) == 4
assert len(col.find_cards("card:2")) == 1
# fields
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("front:dog")) == 1
assert len(col.find_cards("-front:dog")) == 4
assert len(col.find_cards("front:sheep")) == 0
assert len(col.find_cards("back:sheep")) == 2
assert len(col.find_cards("-back:sheep")) == 3
assert len(col.find_cards("front:do")) == 0
assert len(col.find_cards("front:*")) == 5
# ordering
col.conf["sortType"] = "noteCrt"
2021-06-27 07:12:22 +02:00
assert col.find_cards("front:*", order=True)[-1] in latestCardIds
assert col.find_cards("", order=True)[-1] in latestCardIds
col.conf["sortType"] = "noteFld"
2021-06-27 07:12:22 +02:00
assert col.find_cards("", order=True)[0] == catCard.id
assert col.find_cards("", order=True)[-1] in latestCardIds
col.conf["sortType"] = "cardMod"
2021-06-27 07:12:22 +02:00
assert col.find_cards("", order=True)[-1] in latestCardIds
assert col.find_cards("", order=True)[0] == firstCardId
col.set_config(BrowserConfig.CARDS_SORT_BACKWARDS_KEY, True)
2021-06-27 07:12:22 +02:00
assert col.find_cards("", order=True)[0] in latestCardIds
assert (
col.find_cards("", order=col.get_browser_column("cardDue"), reverse=False)[0]
== firstCardId
)
assert (
col.find_cards("", order=col.get_browser_column("cardDue"), reverse=True)[0]
!= firstCardId
)
# model
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("note:basic")) == 3
assert len(col.find_cards("-note:basic")) == 2
assert len(col.find_cards("-note:foo")) == 5
# col
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("deck:default")) == 5
assert len(col.find_cards("-deck:default")) == 0
assert len(col.find_cards("-deck:foo")) == 5
assert len(col.find_cards("deck:def*")) == 5
assert len(col.find_cards("deck:*EFAULT")) == 5
assert len(col.find_cards("deck:*cefault")) == 0
# full search
note = col.newNote()
note["Front"] = "hello<b>world</b>"
note["Back"] = "abc"
col.addNote(note)
# as it's the sort field, it matches
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("helloworld")) == 2
# assert len(col.find_cards("helloworld", full=True)) == 2
# if we put it on the back, it won't
(note["Front"], note["Back"]) = (note["Back"], note["Front"])
note.flush()
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("helloworld")) == 0
# assert len(col.find_cards("helloworld", full=True)) == 2
# assert len(col.find_cards("back:helloworld", full=True)) == 2
# searching for an invalid special tag should not error
with pytest.raises(Exception):
2021-06-27 07:12:22 +02:00
len(col.find_cards("is:invalid"))
# should be able to limit to parent col, no children
id = col.db.scalar("select id from cards limit 1")
col.db.execute(
"update cards set did = ? where id = ?", col.decks.id("Default::Child"), id
2019-12-25 05:18:34 +01:00
)
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("deck:default")) == 7
assert len(col.find_cards("deck:default::child")) == 1
assert len(col.find_cards("deck:default -deck:default::*")) == 6
# properties
id = col.db.scalar("select id from cards limit 1")
col.db.execute(
"update cards set queue=2, ivl=10, reps=20, due=30, factor=2200 "
2019-12-25 05:18:34 +01:00
"where id = ?",
id,
)
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("prop:ivl>5")) == 1
assert len(col.find_cards("prop:ivl<5")) > 1
assert len(col.find_cards("prop:ivl>=5")) == 1
assert len(col.find_cards("prop:ivl=9")) == 0
assert len(col.find_cards("prop:ivl=10")) == 1
assert len(col.find_cards("prop:ivl!=10")) > 1
assert len(col.find_cards("prop:due>0")) == 1
# due dates should work
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("prop:due=29")) == 0
assert len(col.find_cards("prop:due=30")) == 1
# ease factors
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("prop:ease=2.3")) == 0
assert len(col.find_cards("prop:ease=2.2")) == 1
assert len(col.find_cards("prop:ease>2")) == 1
assert len(col.find_cards("-prop:ease>2")) > 1
# recently failed
2020-03-22 05:41:01 +01:00
if not isNearCutoff():
2021-01-10 16:25:52 +01:00
# rated
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("rated:1:1")) == 0
assert len(col.find_cards("rated:1:2")) == 0
c = col.sched.getCard()
col.sched.answerCard(c, 2)
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("rated:1:1")) == 0
assert len(col.find_cards("rated:1:2")) == 1
c = col.sched.getCard()
col.sched.answerCard(c, 1)
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("rated:1:1")) == 1
assert len(col.find_cards("rated:1:2")) == 1
assert len(col.find_cards("rated:1")) == 2
assert len(col.find_cards("rated:2:2")) == 1
assert len(col.find_cards("rated:0")) == len(col.find_cards("rated:1"))
2021-01-10 16:25:52 +01:00
2020-03-22 05:41:01 +01:00
# added
col.db.execute("update cards set id = id - 86400*1000 where id = ?", id)
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("added:1")) == col.card_count() - 1
assert len(col.find_cards("added:2")) == col.card_count()
assert len(col.find_cards("added:0")) == len(col.find_cards("added:1"))
2020-03-22 05:41:01 +01:00
else:
print("some find tests disabled near cutoff")
# empty field
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("front:")) == 0
note = col.newNote()
note["Front"] = ""
note["Back"] = "abc2"
assert col.addNote(note) == 1
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("front:")) == 1
# OR searches and nesting
2021-06-27 07:12:22 +02:00
assert len(col.find_cards("tag:monkey or tag:sheep")) == 2
assert len(col.find_cards("(tag:monkey OR tag:sheep)")) == 2
assert len(col.find_cards("-(tag:monkey OR tag:sheep)")) == 6
assert len(col.find_cards("tag:monkey or (tag:sheep sheep)")) == 2
assert len(col.find_cards("tag:monkey or (tag:sheep octopus)")) == 1
2019-11-13 17:41:34 +01:00
# flag
with pytest.raises(Exception):
2021-06-27 07:12:22 +02:00
col.find_cards("flag:12")
2019-12-25 05:18:34 +01:00
def test_findReplace():
col = getEmptyCol()
note = col.newNote()
note["Front"] = "foo"
note["Back"] = "bar"
col.addNote(note)
note2 = col.newNote()
note2["Front"] = "baz"
note2["Back"] = "foo"
col.addNote(note2)
nids = [note.id, note2.id]
# should do nothing
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
assert (
col.find_and_replace(note_ids=nids, search="abc", replacement="123").count == 0
)
# global replace
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
assert (
col.find_and_replace(note_ids=nids, search="foo", replacement="qux").count == 2
)
note.load()
assert note["Front"] == "qux"
note2.load()
assert note2["Back"] == "qux"
# single field replace
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
assert (
col.find_and_replace(
note_ids=nids, search="qux", replacement="foo", field_name="Front"
).count
== 1
)
note.load()
assert note["Front"] == "foo"
note2.load()
assert note2["Back"] == "qux"
# regex replace
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
assert (
col.find_and_replace(note_ids=nids, search="B.r", replacement="reg").count == 0
)
note.load()
assert note["Back"] != "reg"
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
assert (
col.find_and_replace(
note_ids=nids, search="B.r", replacement="reg", regex=True
).count
== 1
)
note.load()
assert note["Back"] == "reg"
2019-12-25 05:18:34 +01:00
def test_findDupes():
col = getEmptyCol()
note = col.newNote()
note["Front"] = "foo"
note["Back"] = "bar"
col.addNote(note)
note2 = col.newNote()
note2["Front"] = "baz"
note2["Back"] = "bar"
col.addNote(note2)
2020-07-17 17:33:58 +02:00
note3 = col.newNote()
note3["Front"] = "quux"
note3["Back"] = "bar"
col.addNote(note3)
2020-07-17 17:34:39 +02:00
note4 = col.newNote()
note4["Front"] = "quuux"
note4["Back"] = "nope"
col.addNote(note4)
2021-06-27 07:12:22 +02:00
r = col.find_dupes("Back")
assert r[0][0] == "bar"
assert len(r[0][1]) == 3
# valid search
2021-06-27 07:12:22 +02:00
r = col.find_dupes("Back", "bar")
assert r[0][0] == "bar"
assert len(r[0][1]) == 3
# excludes everything
2021-06-27 07:12:22 +02:00
r = col.find_dupes("Back", "invalid")
assert not r
# front isn't dupe
2021-06-27 07:12:22 +02:00
assert col.find_dupes("Front") == []