diff --git a/anki/sched.py b/anki/sched.py index be6242b85..97c626aea 100644 --- a/anki/sched.py +++ b/anki/sched.py @@ -17,11 +17,11 @@ from anki.hooks import runHook # queue types: 0=new/cram, 1=lrn, 2=rev, 3=day lrn, -1=suspended, -2=buried # revlog types: 0=lrn, 1=rev, 2=relrn, 3=cram # positive revlog intervals are in days (rev), negative in seconds (lrn) +# odue/odid store original due/did when cards moved to filtered deck class Scheduler: name = "std" haveCustomStudy = True - _spreadRev = True _burySiblingsOnAnswer = True def __init__(self, col): @@ -57,42 +57,52 @@ class Scheduler: def answerCard(self, card, ease): self.col.log() assert 1 <= ease <= 4 + assert 0 <= card.queue <= 3 self.col.markReview(card) if self._burySiblingsOnAnswer: self._burySiblings(card) card.reps += 1 - # former is for logging new cards, latter also covers filt. decks - card.wasNew = card.type == 0 - wasNewQ = card.queue == 0 - if wasNewQ: - # came from the new queue, move to learning - card.queue = 1 - # if it was a new card, it's now a learning card - if card.type == 0: - card.type = 1 - # init reps to graduation - card.left = self._startingLeft(card) - # dynamic? - if card.odid and card.type == 2: - if self._resched(card): - # reviews get their ivl boosted on first sight - card.ivl = self._dynIvlBoost(card) - card.odue = self.today + card.ivl - self._updateStats(card, 'new') - if card.queue in (1, 3): - self._answerLrnCard(card, ease) - if not wasNewQ: - self._updateStats(card, 'lrn') - elif card.queue == 2: - self._answerRevCard(card, ease) - self._updateStats(card, 'rev') + + # filtered cards handled differently + if card.odid: + self._answerFilteredCard(card, ease) else: - raise Exception("Invalid queue") + self._answerNormalCard(card, ease) + self._updateStats(card, 'time', card.timeTaken()) card.mod = intTime() card.usn = self.col.usn() card.flushSched() + def _answerNormalCard(self, card, ease): + card.wasNew = card.type == 0 + + if card.queue == 0: + # came from the new queue, move to learning + card.queue = 1 + card.type = 1 + # init reps to graduation + card.left = self._startingLeft(card) + # update daily limit + self._updateStats(card, 'new') + + if card.queue in (1, 3): + self._answerLrnCard(card, ease) + #if not wasNewQ: + # self._updateStats(card, 'lrn') + elif card.queue == 2: + self._answerRevCard(card, ease) + # update daily limit + self._updateStats(card, 'rev') + + def _answerFilteredCard(self, card, ease): + # a review card that wasn't due? + if card.queue == 0 and card.type == 2: + self._answerEarlyRevCard(card, ease) + return + + return self._answerNormalCard(card, ease) + def counts(self, card=None): counts = [self.newCount, self.lrnCount, self.revCount] if card: @@ -127,18 +137,19 @@ order by due""" % self._deckLimit(), return card.queue def answerButtons(self, card): - if card.odue: - # normal review in dyn deck? - if card.odid and card.queue == 2: - return 4 - conf = self._lrnConf(card) - if card.type in (0,1) or len(conf['delays']) > 1: - return 3 - return 2 - elif card.queue == 2: - return 4 - else: - return 3 + conf = self._cardConf(card) + + # fixme: resched=off case + # if card.odid: + # if not conf['resched']: + # if card.queue == 2: + # return 4 + # conf = self._lrnConf(card) + # if card.type in (0,1) or len(conf['delays']) > 1: + # return 3 + # return 2 + + return 4 def unburyCards(self): "Unbury cards." @@ -516,66 +527,90 @@ did = ? and queue = 3 and due <= ? limit ?""", return self.col.getCard(self._lrnDayQueue.pop()) def _answerLrnCard(self, card, ease): - # ease 1=no, 2=yes, 3=remove conf = self._lrnConf(card) - if card.odid and not card.wasNew: - type = 3 - elif card.type == 2: + if card.type == 2: type = 2 else: type = 0 - leaving = False # lrnCount was decremented once when card was fetched lastLeft = card.left # immediate graduate? - if ease == 3: + if ease == 4: self._rescheduleAsRev(card, conf, True) - leaving = True - # graduation time? - elif ease == 2 and (card.left%1000)-1 <= 0: - self._rescheduleAsRev(card, conf, False) - leaving = True + self._logLrn(card, ease, conf, True, type, lastLeft) + # next step? + elif ease == 3: + # graduation time? + if (card.left%1000)-1 <= 0: + self._rescheduleAsRev(card, conf, False) + self._logLrn(card, ease, conf, True, type, lastLeft) + else: + self._moveToNextStep(card, conf) + self._logLrn(card, ease, conf, False, type, lastLeft) + elif ease == 2: + self._repeatStep(card, conf) + self._logLrn(card, ease, conf, False, type, lastLeft) else: - # one step towards graduation - if ease == 2: - # decrement real left count and recalculate left today - left = (card.left % 1000) - 1 - card.left = self._leftToday(conf['delays'], left)*1000 + left - # failed - else: - card.left = self._startingLeft(card) - resched = self._resched(card) - if 'mult' in conf and resched: - # review that's lapsed - card.ivl = max(1, conf['minInt'], card.ivl*conf['mult']) - else: - # new card; no ivl adjustment - pass - if resched and card.odid: - card.odue = self.today + 1 + # back to first step + self._moveToFirstStep(card, conf) + self._logLrn(card, ease, conf, False, type, lastLeft) + + def _moveToFirstStep(self, card, conf): + card.left = self._startingLeft(card) + resched = self._resched(card) + + card.lastIvl = card.ivl + if card.type == 2 and resched: + # review card that will move to relearning + card.ivl = self._lapseIvl(card, self._lrnConf(card)) + + if card.queue not in (1,3): + card.lapses += 1 + card.factor = max(1300, card.factor-200) + + # if no relearning steps, reschedule as review immediately + if not conf['delays']: + self._rescheduleAsRev(card, conf, False) + return + + return self._rescheduleLrnCard(card, conf) + + def _moveToNextStep(self, card, conf): + # decrement real left count and recalculate left today + left = (card.left % 1000) - 1 + card.left = self._leftToday(conf['delays'], left)*1000 + left + + self._rescheduleLrnCard(card, conf) + + def _repeatStep(self, card, conf): + delay = self._delayForRepeatingGrade(conf, card.left) + self._rescheduleLrnCard(card, conf, delay=delay) + + def _rescheduleLrnCard(self, card, conf, delay=None): + if delay is None: delay = self._delayForGrade(conf, card.left) - if card.due < time.time(): - # not collapsed; add some randomness - delay *= random.uniform(1, 1.25) - card.due = int(time.time() + delay) - # due today? - if card.due < self.dayCutoff: - self.lrnCount += card.left // 1000 - # if the queue is not empty and there's nothing else to do, make - # sure we don't put it at the head of the queue and end up showing - # it twice in a row - card.queue = 1 - if self._lrnQueue and not self.revCount and not self.newCount: - smallestDue = self._lrnQueue[0][0] - card.due = max(card.due, smallestDue+1) - heappush(self._lrnQueue, (card.due, card.id)) - else: - # the card is due in one or more days, so we need to use the - # day learn queue - ahead = ((card.due - self.dayCutoff) // 86400) + 1 - card.due = self.today + ahead - card.queue = 3 - self._logLrn(card, ease, conf, leaving, type, lastLeft) + if card.due < time.time(): + # not collapsed; add some randomness + delay *= random.uniform(1, 1.25) + card.due = int(time.time() + delay) + # due today? + if card.due < self.dayCutoff: + self.lrnCount += card.left // 1000 + # if the queue is not empty and there's nothing else to do, make + # sure we don't put it at the head of the queue and end up showing + # it twice in a row + card.queue = 1 + if self._lrnQueue and not self.revCount and not self.newCount: + smallestDue = self._lrnQueue[0][0] + card.due = max(card.due, smallestDue+1) + heappush(self._lrnQueue, (card.due, card.id)) + else: + # the card is due in one or more days, so we need to use the + # day learn queue + ahead = ((card.due - self.dayCutoff) // 86400) + 1 + card.due = self.today + ahead + card.queue = 3 + return delay def _delayForGrade(self, conf, left): left = left % 1000 @@ -589,6 +624,13 @@ did = ? and queue = 3 and due <= ? limit ?""", delay = 1 return delay*60 + def _delayForRepeatingGrade(self, conf, left): + # halfway between last and next + delay1 = self._delayForGrade(conf, left) + delay2 = self._delayForGrade(conf, left-1) + avg = (delay1+max(delay1, delay2))//2 + return avg + def _lrnConf(self, card): if card.type == 2: return self._lapseConf(card) @@ -597,26 +639,31 @@ did = ? and queue = 3 and due <= ? limit ?""", def _rescheduleAsRev(self, card, conf, early): lapse = card.type == 2 - if lapse: - if self._resched(card): - card.due = max(self.today+1, card.odue) - else: - card.due = card.odue - card.odue = 0 - else: - self._rescheduleNew(card, conf, early) - card.queue = 2 - card.type = 2 - # if we were dynamic, graduating means moving back to the old deck resched = self._resched(card) + + if resched: + if lapse: + self._rescheduleGraduatingLapse(card) + else: + self._rescheduleNew(card, conf, early) + + # if we were dynamic, graduating means moving back to the old deck if card.odid: + # and leaving learning if scheduling off + if not resched: + card.due = card.odue + if card.type == 2: + card.queue = 2 + else: + card.queue = card.type = 0 + card.did = card.odid card.odue = 0 card.odid = 0 - # if rescheduling is off, it needs to be set back to a new card - if not resched and not lapse: - card.queue = card.type = 0 - card.due = self.col.nextID("pos") + + def _rescheduleGraduatingLapse(self, card): + card.due = self.today+card.ivl + card.queue = 2 def _startingLeft(self, card): if card.type == 2: @@ -640,12 +687,8 @@ did = ? and queue = 3 and due <= ? limit ?""", ok = i return ok+1 - def _graduatingIvl(self, card, conf, early, adj=True): + def _graduatingIvl(self, card, conf, early, fuzz=True): if card.type == 2: - # lapsed card being relearnt - if card.odid: - if conf['resched']: - return self._dynIvlBoost(card) return card.ivl if not early: # graduate @@ -653,16 +696,16 @@ did = ? and queue = 3 and due <= ? limit ?""", else: # early remove ideal = conf['ints'][1] - if adj: - return self._adjRevIvl(card, ideal) - else: - return ideal + if fuzz: + ideal = self._fuzzedIvl(ideal) + return ideal def _rescheduleNew(self, card, conf, early): "Reschedule a new card that's graduated for the first time." card.ivl = self._graduatingIvl(card, conf, early) card.due = self.today+card.ivl card.factor = conf['initialFactor'] + card.type = card.queue = 2 def _logLrn(self, card, ease, conf, leaving, type, lastLeft): lastIvl = -(self._delayForGrade(conf, lastLeft)) @@ -690,10 +733,11 @@ did = ? and queue = 3 and due <= ? limit ?""", # review cards in relearning self.col.db.execute(""" update cards set -due = odue, queue = 2, mod = %d, usn = %d, odue = 0 +due = ivl+?, queue = 2, mod = %d, usn = %d, +odue = 0 -- older anki versions set odue when cards in relearning where queue in (1,3) and type = 2 %s -""" % (intTime(), self.col.usn(), extra)) +""" % (intTime(), self.col.usn(), extra), self.today) # new cards in learning self.forgetCards(self.col.db.list( "select id from cards where queue in (1,3) %s" % extra)) @@ -806,48 +850,36 @@ select id from cards where did in %s and queue = 2 and due <= ? limit ?)""" self._rescheduleRev(card, ease) self._logRev(card, ease, delay) + def _answerEarlyRevCard(self, card, ease): + delay = 0 + if ease == 1: + delay = self._rescheduleLapse(card) + else: + self._rescheduleEarlyRev(card, ease) + + self._logRev(card, ease, delay, type=3) + def _rescheduleLapse(self, card): conf = self._lapseConf(card) - card.lastIvl = card.ivl - if self._resched(card): - card.lapses += 1 - card.ivl = self._nextLapseIvl(card, conf) - card.factor = max(1300, card.factor-200) - card.due = self.today + card.ivl - # if it's a filtered deck, update odue as well - if card.odid: - card.odue = card.due + delay = self._moveToFirstStep(card, conf) + # if suspended as a leech, nothing to do - delay = 0 if self._checkLeech(card, conf) and card.queue == -1: - return delay - # if no relearning steps, nothing to do - if not conf['delays']: - return delay - # record rev due date for later - if not card.odue: - card.odue = card.due - delay = self._delayForGrade(conf, 0) - card.due = int(delay + time.time()) - card.left = self._startingLeft(card) - # queue 1 - if card.due < self.dayCutoff: - self.lrnCount += card.left // 1000 - card.queue = 1 - heappush(self._lrnQueue, (card.due, card.id)) - else: - # day learn queue - ahead = ((card.due - self.dayCutoff) // 86400) + 1 - card.due = self.today + ahead - card.queue = 3 + return 0 + return delay - def _nextLapseIvl(self, card, conf): - return max(conf['minInt'], int(card.ivl*conf['mult'])) + def _lapseIvl(self, card, conf): + due = card.odue or card.due + elapsed = card.ivl - (due - self.today) + ivl = min(elapsed, card.ivl) + ivl = max(1, conf['minInt'], ivl*conf['mult']) + return ivl def _rescheduleRev(self, card, ease): # update interval card.lastIvl = card.ivl + if self._resched(card): self._updateRevIvl(card, ease) # then the rest @@ -855,18 +887,41 @@ select id from cards where did in %s and queue = 2 and due <= ? limit ?)""" card.due = self.today + card.ivl else: card.due = card.odue + + # card leaves filtered deck if card.odid: card.did = card.odid card.odid = 0 card.odue = 0 - def _logRev(self, card, ease, delay): + def _rescheduleEarlyRev(self, card, ease): + # update interval + card.lastIvl = card.ivl + + if self._resched(card): + self._updateEarlyRevIvl(card, ease) + # then the rest + card.factor = max(1300, card.factor+[-150, 0, 150][ease-2]) + card.due = self.today + card.ivl + else: + card.due = card.odue + + # move from 0->2 + card.queue = 2 + + # card leaves filtered deck + if card.odid: + card.did = card.odid + card.odid = 0 + card.odue = 0 + + def _logRev(self, card, ease, delay, type=1): def log(): self.col.db.execute( "insert into revlog values (?,?,?,?,?,?,?,?,?)", int(time.time()*1000), card.id, self.col.usn(), ease, -delay or card.ivl, card.lastIvl, card.factor, card.timeTaken(), - 1) + type) try: log() except: @@ -926,13 +981,49 @@ select id from cards where did in %s and queue = 2 and due <= ? limit ?)""" def _updateRevIvl(self, card, ease): idealIvl = self._nextRevIvl(card, ease) - card.ivl = min(max(self._adjRevIvl(card, idealIvl), card.ivl+1), + card.ivl = min(max(self._fuzzedIvl(idealIvl), card.ivl+1), self._revConf(card)['maxIvl']) - def _adjRevIvl(self, card, idealIvl): - if self._spreadRev: - idealIvl = self._fuzzedIvl(idealIvl) - return idealIvl + def _updateEarlyRevIvl(self, card, ease): + idealIvl = self._earlyReviewIvl(card, ease) + card.ivl = min(max(self._fuzzedIvl(idealIvl), card.ivl+1), + self._revConf(card)['maxIvl']) + + # next interval for card when answered early+correctly + def _earlyReviewIvl(self, card, ease): + assert card.odid and card.type == 2 + assert card.factor + assert ease > 1 + + elapsed = card.ivl - (card.odue - self.today) + + conf = self._revConf(card) + + easyBonus = 0 + # early 3/4 reviews shouldn't decrease previous interval + minNewIvl = 1 + + if ease == 2: + factor = 1.2 + # hard cards shouldn't have their interval decreased by more than 50% + minNewIvl = 0.5 + elif ease == 3: + factor = card.factor / 1000 + else: # ease == 4: + factor = card.factor / 1000 * conf['ease4'] + # add an extra day, so early reviews have an easy interval nominally + # different from the good answer + easyBonus = 1 + + ivl = max(elapsed * factor, 1) + + # cap interval decreases + ivl = max(card.ivl*minNewIvl+easyBonus, ivl) + + # don't require prev+1 for early + ivl = self._constrainedIvl(ivl, conf, 0) + + return min(conf['maxIvl'], ivl) # Dynamic deck handling ########################################################################## @@ -971,15 +1062,37 @@ select id from cards where did in %s and queue = 2 and due <= ? limit ?)""" if not lim: lim = "did = %s" % did self.col.log(self.col.db.list("select id from cards where %s" % lim)) - # move out of cram queue + self.col.db.execute(""" -update cards set did = odid, queue = (case when type = 1 then 0 -else type end), type = (case when type = 1 then 0 else type end), +update cards set did = odid, queue = (case when queue = 0 then type else queue end), due = odue, odue = 0, odid = 0, usn = ? where %s""" % lim, self.col.usn()) def remFromDyn(self, cids): - self.emptyDyn(None, "id in %s and odid" % ids2str(cids)) + for did, grpcids in self._cidsByDid(cids): + self.emptyDyn(did, "id in %s" % ids2str(grpcids)) + + def _cidsByDid(self, cids): + groups = [] + currentCids = [] + currentDid = None + for id, did in self.col.db.execute(""" +select id, did from cards where id in %s and odid +group by did +""" % ids2str(cids)): + # next group? + if did != currentDid: + if currentCids: + groups.append((currentDid, currentCids)) + currentCids = [] + currentDid = did + + currentCids.append(id) + + if currentCids: + groups.append((currentDid, currentCids)) + + return groups def _dynOrder(self, o, l): if o == DYN_OLDEST: @@ -1013,26 +1126,22 @@ due = odue, odue = 0, odid = 0, usn = ? where %s""" % lim, for c, id in enumerate(ids): # start at -100000 so that reviews are all due data.append((did, -100000+c, u, id)) - # due reviews stay in the review queue. careful: can't use - # "odid or did", as sqlite converts to boolean - queue = """ -(case when type=2 and (case when odue then odue <= %d else due <= %d end) - then 2 else 0 end)""" - queue %= (self.today, self.today) - self.col.db.executemany(""" -update cards set -odid = (case when odid then odid else did end), -odue = (case when odue then odue else due end), -did = ?, queue = %s, due = ?, usn = ? where id = ?""" % queue, data) - def _dynIvlBoost(self, card): - assert card.odid and card.type == 2 - assert card.factor - elapsed = card.ivl - (card.odue - self.today) - factor = ((card.factor/1000)+1.2)/2 - ivl = int(max(card.ivl, elapsed * factor, 1)) - conf = self._revConf(card) - return min(conf['maxIvl'], ivl) + # fixme: put due reviews in new queue in the future + queue = """ +(case +when queue=2 and due<=%d then queue +when queue in (1,3) then queue +else 0 end)""" + queue %= self.today + + query = """ +update cards set +odid = did, odue = due, +did = ?, queue = %s, due = ?, usn = ? where id = ? +""" % queue + + self.col.db.executemany(query, data) # Leeches ########################################################################## @@ -1076,14 +1185,13 @@ did = ?, queue = %s, due = ?, usn = ? where id = ?""" % queue, data) return conf['new'] # dynamic deck; override some attributes, use original deck for others oconf = self.col.decks.confForDid(card.odid) - delays = conf['delays'] or oconf['new']['delays'] return dict( # original deck ints=oconf['new']['ints'], initialFactor=oconf['new']['initialFactor'], bury=oconf['new'].get("bury", True), + delays=oconf['new']['delays'], # overrides - delays=delays, separate=conf['separate'], order=NEW_CARDS_DUE, perDay=self.reportLimit @@ -1096,15 +1204,14 @@ did = ?, queue = %s, due = ?, usn = ? where id = ?""" % queue, data) return conf['lapse'] # dynamic deck; override some attributes, use original deck for others oconf = self.col.decks.confForDid(card.odid) - delays = conf['delays'] or oconf['lapse']['delays'] return dict( # original deck minInt=oconf['lapse']['minInt'], leechFails=oconf['lapse']['leechFails'], leechAction=oconf['lapse']['leechAction'], mult=oconf['lapse']['mult'], + delays=oconf['lapse']['delays'], # overrides - delays=delays, resched=conf['resched'], ) @@ -1224,14 +1331,19 @@ To study outside of the normal schedule, click the Custom Study button below.""" def nextIvl(self, card, ease): "Return the next interval for CARD, in seconds." - if card.queue in (0,1,3): + if card.queue == 0 and card.type == 2 and ease > 1: + if self._resched(card): + return self._earlyReviewIvl(card, ease)*86400 + else: + return 0 + elif card.queue in (0,1,3): return self._nextLrnIvl(card, ease) elif ease == 1: # lapsed conf = self._lapseConf(card) if conf['delays']: return conf['delays'][0]*60 - return self._nextLapseIvl(card, conf)*86400 + return self._lapseIvl(card, conf)*86400 else: # review return self._nextRevIvl(card, ease)*86400 @@ -1244,18 +1356,20 @@ To study outside of the normal schedule, click the Custom Study button below.""" if ease == 1: # fail return self._delayForGrade(conf, len(conf['delays'])) - elif ease == 3: + elif ease == 2: + return self._delayForRepeatingGrade(conf, card.left) + elif ease == 4: # early removal if not self._resched(card): return 0 - return self._graduatingIvl(card, conf, True, adj=False) * 86400 - else: + return self._graduatingIvl(card, conf, True, fuzz=False) * 86400 + else: # ease == 3 left = card.left%1000 - 1 if left <= 0: # graduate if not self._resched(card): return 0 - return self._graduatingIvl(card, conf, False, adj=False) * 86400 + return self._graduatingIvl(card, conf, False, fuzz=False) * 86400 else: return self._delayForGrade(conf, left) diff --git a/aqt/customstudy.py b/aqt/customstudy.py index d16007513..97ed08085 100644 --- a/aqt/customstudy.py +++ b/aqt/customstudy.py @@ -133,19 +133,15 @@ class CustomStudy(QDialog): dyn = self.mw.col.decks.get(did) # and then set various options if i == RADIO_FORGOT: - dyn['delays'] = [1] dyn['terms'][0] = ['rated:%d:1' % spin, DYN_MAX_SIZE, DYN_RANDOM] dyn['resched'] = False elif i == RADIO_AHEAD: - dyn['delays'] = None dyn['terms'][0] = ['prop:due<=%d' % spin, DYN_MAX_SIZE, DYN_DUE] dyn['resched'] = True elif i == RADIO_PREVIEW: - dyn['delays'] = None dyn['terms'][0] = ['is:new added:%s'%spin, DYN_MAX_SIZE, DYN_OLDEST] dyn['resched'] = False elif i == RADIO_CRAM: - dyn['delays'] = None type = f.cardType.currentRow() if type == TYPE_NEW: terms = "is:new " diff --git a/aqt/dyndeckconf.py b/aqt/dyndeckconf.py index cb03638c7..1fc04639d 100644 --- a/aqt/dyndeckconf.py +++ b/aqt/dyndeckconf.py @@ -43,12 +43,6 @@ class DeckConf(QDialog): d = self.deck search, limit, order = d['terms'][0] f.search.setText(search) - if d['delays']: - f.steps.setText(self.listToUser(d['delays'])) - f.stepsOn.setChecked(True) - else: - f.steps.setText("1 10") - f.stepsOn.setChecked(False) f.resched.setChecked(d['resched']) f.order.setCurrentIndex(order) f.limit.setValue(limit) @@ -57,12 +51,6 @@ class DeckConf(QDialog): f = self.form d = self.deck d['delays'] = None - if f.stepsOn.isChecked(): - steps = self.userToList(f.steps) - if steps: - d['delays'] = steps - else: - d['delays'] = None d['terms'][0] = [f.search.text(), f.limit.value(), f.order.currentIndex()] diff --git a/designer/dyndconf.ui b/designer/dyndconf.ui index 39ca36f76..c0f8d935c 100644 --- a/designer/dyndconf.ui +++ b/designer/dyndconf.ui @@ -82,23 +82,6 @@ - - - - Custom steps (in minutes) - - - - - - - false - - - 1 10 - - - @@ -132,8 +115,6 @@ limit order resched - stepsOn - steps buttonBox @@ -170,21 +151,5 @@ - - stepsOn - toggled(bool) - steps - setEnabled(bool) - - - 126 - 207 - - - 272 - 205 - - - diff --git a/tests/test_exporting.py b/tests/test_exporting.py index 261e27dcc..6fcb73710 100644 --- a/tests/test_exporting.py +++ b/tests/test_exporting.py @@ -89,8 +89,8 @@ def test_export_anki_due(): deck.crt -= 86400*10 deck.sched.reset() c = deck.sched.getCard() - deck.sched.answerCard(c, 2) - deck.sched.answerCard(c, 2) + deck.sched.answerCard(c, 3) + deck.sched.answerCard(c, 3) # should have ivl of 1, due on day 11 assert c.ivl == 1 assert c.due == 11 diff --git a/tests/test_sched.py b/tests/test_sched.py index 669fa8a03..978e14d42 100644 --- a/tests/test_sched.py +++ b/tests/test_sched.py @@ -8,7 +8,6 @@ from tests.shared import getEmptyCol from anki.utils import intTime from anki.hooks import addHook - def test_clock(): d = getEmptyCol() if (d.sched.dayCutoff - intTime()) < 10*60: @@ -132,18 +131,18 @@ def test_learn(): t = round(c.due - time.time()) assert t >= 25 and t <= 40 # pass it once - d.sched.answerCard(c, 2) + d.sched.answerCard(c, 3) # 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 # check log is accurate log = d.db.first("select * from revlog order by id desc") - assert log[3] == 2 + assert log[3] == 3 assert log[4] == -180 assert log[5] == -30 # pass again - d.sched.answerCard(c, 2) + d.sched.answerCard(c, 3) # it should by due in 10 minutes assert round(c.due - time.time()) in (599, 600) assert c.left%1000 == 1 @@ -151,7 +150,7 @@ def test_learn(): # the next pass should graduate the card assert c.queue == 1 assert c.type == 1 - d.sched.answerCard(c, 2) + d.sched.answerCard(c, 3) assert c.queue == 2 assert c.type == 2 # should be due tomorrow, with an interval of 1 @@ -160,29 +159,48 @@ def test_learn(): # or normal removal c.type = 0 c.queue = 1 - d.sched.answerCard(c, 3) + d.sched.answerCard(c, 4) assert c.type == 2 assert c.queue == 2 assert checkRevIvl(d, c, 4) # revlog should have been updated each time assert d.db.scalar("select count() from revlog where type = 0") == 5 - # now failed card handling - c.type = 2 - c.queue = 1 - c.odue = 123 - d.sched.answerCard(c, 3) - assert c.due == 123 - assert c.type == 2 - assert c.queue == 2 - # we should be able to remove manually, too - c.type = 2 - c.queue = 1 - c.odue = 321 + +def test_relearn(): + d = getEmptyCol() + f = d.newNote() + f['Front'] = "one" + d.addNote(f) + c = f.cards()[0] + c.ivl = 100 + c.due = d.sched.today + c.type = c.queue = 2 c.flush() - d.sched.removeLrn() + + # fail the card + d.reset() + c = d.sched.getCard() + d.sched.answerCard(c, 1) + assert c.queue == 1 + assert c.type == 2 + assert c.ivl == 1 + + # immediately graduate it + d.sched.answerCard(c, 4) + assert c.queue == c.type == 2 + assert c.ivl == 1 + assert c.due == d.sched.today + c.ivl + + # if forced out of learning, it should have the correct due date + c.ivl = 100 + c.due = d.sched.today + c.flush() + c = d.sched.getCard() + d.sched.answerCard(c, 1) + assert c.due > intTime() + d.sched.removeLrn([c.id]) c.load() - assert c.queue == 2 - assert c.due == 321 + assert c.due == d.sched.today + c.ivl def test_learn_collapsed(): d = getEmptyCol() @@ -200,7 +218,7 @@ def test_learn_collapsed(): c = d.sched.getCard() assert c.q().endswith("1") # pass it so it's due in 10 minutes - d.sched.answerCard(c, 2) + d.sched.answerCard(c, 3) # get the other card c = d.sched.getCard() assert c.q().endswith("2") @@ -220,16 +238,16 @@ def test_learn_day(): c = d.sched.getCard() d.sched._cardConf(c)['new']['delays'] = [1, 10, 1440, 2880] # pass it - d.sched.answerCard(c, 2) + d.sched.answerCard(c, 3) # two reps to graduate, 1 more today 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 + assert ni(c, 3) == 86400 # answering it will place it in queue 3 - d.sched.answerCard(c, 2) + d.sched.answerCard(c, 3) assert c.due == d.sched.today+1 assert c.queue == 3 assert not d.sched.getCard() @@ -240,21 +258,21 @@ 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, 3) == 86400*2 # if we fail it, it should be back in the correct queue d.sched.answerCard(c, 1) assert c.queue == 1 d.undo() d.reset() c = d.sched.getCard() - d.sched.answerCard(c, 2) + d.sched.answerCard(c, 3) # simulate the passing of another two days c.due -= 2 c.flush() d.reset() # the last pass should graduate it into a review card - assert ni(c, 2) == 86400 - d.sched.answerCard(c, 2) + assert ni(c, 3) == 86400 + d.sched.answerCard(c, 3) assert c.queue == c.type == 2 # if the lapse step is tomorrow, failing it should handle the counts # correctly @@ -287,33 +305,11 @@ def test_reviews(): c.flush() # save it for later use as well cardcopy = copy.copy(c) - # failing it should put it in the learn queue with the default options - ################################################## - # different delay to new - d.reset() - 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 - assert c.odue == d.sched.today + 1 - assert c.ivl == 1 - # but because it's in the learn queue, its current due time should be in - # the future - assert c.due >= time.time() - assert (c.due - time.time()) > 119 - # factor should have been decremented - assert c.factor == 2300 - # check counters - assert c.lapses == 2 - assert c.reps == 4 - # check ests. - ni = d.sched.nextIvl - assert ni(c, 1) == 120 - assert ni(c, 2) == 20*60 - # try again with an ease of 2 instead + # try with an ease of 2 ################################################## c = copy.copy(cardcopy) c.flush() + d.reset() d.sched.answerCard(c, 2) assert c.queue == 2 # the new interval should be (100 + 8/4) * 1.2 = 122 @@ -448,30 +444,33 @@ def test_nextIvl(): ################################################## ni = d.sched.nextIvl assert ni(c, 1) == 30 - assert ni(c, 2) == 180 - assert ni(c, 3) == 4*86400 + assert ni(c, 2) == (30+180)//2 + assert ni(c, 3) == 180 + assert ni(c, 4) == 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 - d.sched.answerCard(c, 2) + assert ni(c, 2) == (30+180)//2 + assert ni(c, 3) == 180 + assert ni(c, 4) == 4*86400 + d.sched.answerCard(c, 3) assert ni(c, 1) == 30 - assert ni(c, 2) == 600 - assert ni(c, 3) == 4*86400 - d.sched.answerCard(c, 2) + assert ni(c, 2) == (180+600)//2 + assert ni(c, 3) == 600 + assert ni(c, 4) == 4*86400 + d.sched.answerCard(c, 3) # normal graduation is tomorrow - assert ni(c, 2) == 1*86400 - assert ni(c, 3) == 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, 2) == 100*86400 assert ni(c, 3) == 100*86400 + assert ni(c, 4) == 100*86400 # review cards ################################################## c.queue = 2 @@ -547,7 +546,7 @@ def test_suspend(): assert c.due == 1 assert c.did == 1 -def test_cram(): +def test_filt_reviewing_early_normal(): d = getEmptyCol() f = d.newNote() f['Front'] = "one" @@ -563,7 +562,6 @@ def test_cram(): c.flush() d.reset() assert d.sched.counts() == (0,0,0) - cardcopy = copy.copy(c) # create a dynamic deck and refresh it did = d.decks.newDyn("Cram") d.sched.rebuildDyn(did) @@ -574,110 +572,62 @@ def test_cram(): 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.answerButtons(c) == 4 assert d.sched.nextIvl(c, 1) == 600 - assert d.sched.nextIvl(c, 2) == 138*60*60*24 - cram = d.decks.get(did) - 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 - d.sched.answerCard(c, 2) - # elapsed time was 75 days - # factor = 2.5+1.2/2 = 1.85 - # int(75*1.85) = 138 - assert c.ivl == 138 - assert c.odue == 138 - assert c.queue == 1 + 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.3)*86400 + + # answer 'good' + d.sched.answerCard(c, 3) + checkRevIvl(d, c, 90) + assert c.due == d.sched.today + c.ivl + assert not c.odue + # 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 - # 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 - # when it graduates, due is updated - c = d.sched.getCard() - d.sched.answerCard(c, 2) - assert c.ivl == 138 - assert c.due == 138 - assert c.queue == 2 - # and it will have moved back to the previous deck - assert c.did == 1 - # cram the deck again - d.sched.rebuildDyn(did) - d.reset() - c = d.sched.getCard() - # 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 - d.sched.answerCard(c, 2) - assert c.ivl == 138 - assert c.odue == 138 - # fail - d.sched.answerCard(c, 1) - assert d.sched.nextIvl(c, 1) == 60 - assert d.sched.nextIvl(c, 2) == 600 - assert d.sched.nextIvl(c, 3) == 86400 - # delete the deck, returning the card mid-study - d.decks.rem(d.decks.selected()) - assert len(d.sched.deckDueList()) == 1 - c.load() - assert c.ivl == 1 - assert c.due == d.sched.today+1 - # make it due - d.reset() - assert d.sched.counts() == (0,0,0) - c.due = -5 - c.ivl = 100 - c.flush() - d.reset() - 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) - c.load() - assert d.sched.answerButtons(c) == 4 - # add a sibling so we can test minSpace, etc - c.col = None - c2 = copy.deepcopy(c) - c2.col = c.col = d - c2.id = 123 - c2.ord = 1 - c2.due = 325 - c2.col = c.col - c2.flush() - # should be able to answer it - c = d.sched.getCard() - d.sched.answerCard(c, 4) - # it should have been moved back to the original deck - assert c.did == 1 -def test_cram_rem(): + # due in 75 days, so it's been waiting 25 days + c.ivl = 100 + c.due = d.sched.today + 75 + c.flush() + d.sched.rebuildDyn(did) + d.reset() + c = d.sched.getCard() + + assert d.sched.nextIvl(c, 2) == 50*86400 + assert d.sched.nextIvl(c, 3) == 100*86400 + assert d.sched.nextIvl(c, 4) == 101*86400 + +def test_filt_keep_lrn_state(): d = getEmptyCol() f = d.newNote() f['Front'] = "one" d.addNote(f) - oldDue = f.cards()[0].due + + # fail the card outside filtered deck + c = d.sched.getCard() + d.sched.answerCard(c, 1) + + assert c.type == c.queue == 1 + + # create a dynamic deck and refresh it did = d.decks.newDyn("Cram") d.sched.rebuildDyn(did) d.reset() - c = d.sched.getCard() - d.sched.answerCard(c, 2) - # answering the card will put it in the learning queue + + # card should still be in learning state + c.load() assert c.type == c.queue == 1 - assert c.due != oldDue - # if we terminate cramming prematurely it should be set back to new + + # emptying the deck preserves learning state d.sched.emptyDyn(did) c.load() - assert c.type == c.queue == 0 - assert c.due == oldDue + assert c.type == c.queue == 1 -def test_cram_resched(): +def test_filt_reschedoff(): # add card d = getEmptyCol() f = d.newNote() @@ -693,14 +643,16 @@ def test_cram_resched(): c = d.sched.getCard() ni = d.sched.nextIvl assert ni(c, 1) == 60 - assert ni(c, 2) == 600 - assert ni(c, 3) == 0 - assert d.sched.nextIvlStr(c, 3) == "(end)" - d.sched.answerCard(c, 3) + assert ni(c, 2) == (60+600)//2 + assert ni(c, 3) == 600 + assert ni(c, 4) == 0 + assert d.sched.nextIvlStr(c, 4) == "(end)" + d.sched.answerCard(c, 4) assert c.queue == c.type == 0 # undue reviews should also be unaffected c.ivl = 100 - c.type = c.queue = 2 + c.type = 2 + c.queue = 0 c.due = d.sched.today + 25 c.factor = STARTING_FACTOR c.flush() @@ -732,7 +684,7 @@ def test_cram_resched(): d.reset() c = d.sched.getCard() d.sched.answerCard(c, 1) - d.sched.answerCard(c, 3) + d.sched.answerCard(c, 4) d.sched.emptyDyn(did) c.load() assert c.ivl == 100 @@ -769,7 +721,7 @@ def test_cram_resched(): d.reset() c = d.sched.getCard() d.sched.answerCard(c, 1) - d.sched.answerCard(c, 3) + d.sched.answerCard(c, 4) c.load() assert c.ivl == 100 assert c.due == -25 @@ -840,31 +792,31 @@ def test_repCounts(): assert d.sched.counts() == (0, 2, 0) d.sched.answerCard(d.sched.getCard(), 1) assert d.sched.counts() == (0, 2, 0) - d.sched.answerCard(d.sched.getCard(), 2) + d.sched.answerCard(d.sched.getCard(), 3) assert d.sched.counts() == (0, 1, 0) d.sched.answerCard(d.sched.getCard(), 1) assert d.sched.counts() == (0, 2, 0) - d.sched.answerCard(d.sched.getCard(), 2) + d.sched.answerCard(d.sched.getCard(), 3) assert d.sched.counts() == (0, 1, 0) - d.sched.answerCard(d.sched.getCard(), 2) + d.sched.answerCard(d.sched.getCard(), 3) assert d.sched.counts() == (0, 0, 0) f = d.newNote() f['Front'] = "two" d.addNote(f) d.reset() # initial pass should be correct too - d.sched.answerCard(d.sched.getCard(), 2) + d.sched.answerCard(d.sched.getCard(), 3) assert d.sched.counts() == (0, 1, 0) d.sched.answerCard(d.sched.getCard(), 1) assert d.sched.counts() == (0, 2, 0) - d.sched.answerCard(d.sched.getCard(), 3) + d.sched.answerCard(d.sched.getCard(), 4) assert d.sched.counts() == (0, 0, 0) # immediate graduate should work f = d.newNote() f['Front'] = "three" d.addNote(f) d.reset() - d.sched.answerCard(d.sched.getCard(), 3) + d.sched.answerCard(d.sched.getCard(), 4) assert d.sched.counts() == (0, 0, 0) # and failing a review should too f = d.newNote() @@ -917,7 +869,7 @@ def test_collapse(): c = d.sched.getCard() d.sched.answerCard(c, 1) c = d.sched.getCard() - d.sched.answerCard(c, 3) + d.sched.answerCard(c, 4) assert not d.sched.getCard() def test_deckDue(): @@ -1001,7 +953,7 @@ def test_deckFlow(): for i in "one", "three", "two": c = d.sched.getCard() assert c.note()['Front'] == i - d.sched.answerCard(c, 2) + d.sched.answerCard(c, 3) def test_reorder(): d = getEmptyCol() @@ -1111,5 +1063,7 @@ def test_failmult(): c = d.sched.getCard() d.sched.answerCard(c, 1) assert c.ivl == 50 + # failing again, the actual elapsed interval is 0, + # so the card is reset to new d.sched.answerCard(c, 1) - assert c.ivl == 25 + assert c.ivl == 1 diff --git a/tests/test_sync.py b/tests/test_sync.py index 585e68ca9..a61c582e9 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -343,6 +343,7 @@ def test_filtered_delete(): nid = deck1.db.scalar("select id from notes") note = deck1.getNote(nid) card = note.cards()[0] + card.queue = 2 card.type = 2 card.ivl = 10 card.factor = STARTING_FACTOR diff --git a/tests/test_undo.py b/tests/test_undo.py index a9b06b34c..457112987 100644 --- a/tests/test_undo.py +++ b/tests/test_undo.py @@ -47,7 +47,7 @@ def test_review(): assert d.sched.counts() == (1, 0, 0) c = d.sched.getCard() assert c.queue == 0 - d.sched.answerCard(c, 2) + d.sched.answerCard(c, 3) assert c.left == 1001 assert d.sched.counts() == (0, 1, 0) assert c.queue == 1 @@ -67,9 +67,9 @@ def test_review(): d.reset() assert d.sched.counts() == (2, 0, 0) c = d.sched.getCard() - d.sched.answerCard(c, 2) + d.sched.answerCard(c, 3) c = d.sched.getCard() - d.sched.answerCard(c, 2) + d.sched.answerCard(c, 3) assert d.sched.counts() == (0, 2, 0) d.undo() d.reset() @@ -79,7 +79,7 @@ def test_review(): assert d.sched.counts() == (2, 0, 0) # performing a normal op will clear the review queue c = d.sched.getCard() - d.sched.answerCard(c, 2) + d.sched.answerCard(c, 3) assert d.undoName() == "Review" d.save("foo") assert d.undoName() == "foo"