Merge suspend and resume (#219)

V-561, and the owner's acceptance transcript. A side query no longer kills the
flow it interrupted. The answer comes first and the parked question comes back
in the same reply.

  напомни позвонить маме        -> Когда?
  какая сейчас погода в Риме?   -> погода не настроена, на какое время
                                   поставить напоминание?
  в 21:00                       -> хорошо, напомню сегодня в 21:00.

The owner rejected "Прошлую просьбу отпускаю." for this shape. It is kept for
new_request and cancel, where something really was dropped, and gone for
side_query.

clarifyResumedVariants is a new deck, one wording per slot, all infinitive so
there is no gender to get wrong. A resume spends no attempt, so it is not an
attempt ladder.

Resume is a deferred call in runTurn rather than a call at each exit. Eight
returns sit between the fall-through and the replier, and one that forgot would
park a request for ever.

askClarify pushes rather than puts when a side query needs clarifying of its
own. Put would replace the top, which is the flow the side query was allowed to
interrupt rather than kill.

TakeExpired returns a count, not a bool. The stack holds two and drops both when
the top times out, so the singular "прошлую просьбу" would have been a lie
about the number. clarifyExpiredPluralVariants covers it.

The contract row for a nested question is green and unskipped. The owner's own
transcript stays skipped, because StubDateTimeParser reads neither "на 9" nor
"на завтра". That is V-543 and V-562, and the skip reason now says so.
This commit is contained in:
2026-08-06 01:34:32 +04:00
10 changed files with 299 additions and 62 deletions
+171 -21
View File
@@ -6,6 +6,8 @@ import (
"math/rand"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/router"
@@ -65,8 +67,28 @@ var clarifyExpiredVariants = []string{
"Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.",
}
// clarifyExpiredLine picks one of them at random.
func clarifyExpiredLine() string {
// clarifyExpiredPluralVariants — the same notice when TWO parked requests died
// together (Vikunja #561). Since a side query suspends the flow instead of
// dropping it, the stack can hold both the flow and the thing he interrupted it
// with, and TakeExpired drops the whole stack when the top times out. "Прошлую
// просьбу" would then be a lie about the count: he loses two and hears about
// one.
//
// Two phrasings only, against five for the singular. This fires when he walks
// off in the middle of an interrupted exchange, which is rarer than walking off
// in the middle of a plain one, so it repeats less and needs less variety.
var clarifyExpiredPluralVariants = []string{
"Прости, я слишком долго ждала и отпустила обе прошлые просьбы. Если они ещё нужны, скажи заново.",
"Я не дождалась ответа и убрала обе прошлые просьбы. Повтори, если они всё ещё нужны.",
}
// clarifyExpiredLine picks one of them at random. n is how many requests died;
// anything above one gets the plural wording, because the bound is two today and
// a third would still be "обе" short of the truth only if MaxStackDepth grew.
func clarifyExpiredLine(n int) string {
if n > 1 {
return clarifyExpiredPluralVariants[rand.Intn(len(clarifyExpiredPluralVariants))]
}
return clarifyExpiredVariants[rand.Intn(len(clarifyExpiredVariants))]
}
@@ -74,23 +96,34 @@ func clarifyExpiredLine() string {
// notice is glued in front of this turn's reply (see withNotice), so a caller
// checking for it has to match a prefix, not the whole string.
func isClarifyExpired(s string) bool {
for _, v := range clarifyExpiredVariants {
if strings.HasPrefix(s, v) {
return true
}
}
return false
_, ok := cutClarifyExpired(s)
return ok
}
// trimClarifyExpired strips a leading expiry notice, leaving this turn's actual
// reply. "" ⇒ the notice was the whole thing.
func trimClarifyExpired(s string) string {
for _, v := range clarifyExpiredVariants {
if strings.HasPrefix(s, v) {
return strings.TrimSpace(strings.TrimPrefix(s, v))
rest, ok := cutClarifyExpired(s)
if !ok {
return strings.TrimSpace(s)
}
return rest
}
// cutClarifyExpired matches either expiry deck as a prefix and returns what
// follows it. Both decks, since V-561 added the plural line: a caller asking
// "did she say a request timed out" means the fact, not which wording carried
// it, and a helper that knew only the singular would read the two-request
// notice as ordinary reply text.
func cutClarifyExpired(s string) (string, bool) {
for _, deck := range [][]string{clarifyExpiredVariants, clarifyExpiredPluralVariants} {
for _, v := range deck {
if strings.HasPrefix(s, v) {
return strings.TrimSpace(strings.TrimPrefix(s, v)), true
}
}
}
return strings.TrimSpace(s)
return "", false
}
// clarifyExpiredNotice returns that line when a parked question had just timed
@@ -101,11 +134,12 @@ func (h *reactiveHandler) clarifyExpiredNotice(ctx context.Context) string {
if h.clarifyStore == nil {
return ""
}
if !h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now()) {
n := h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now())
if n == 0 {
return ""
}
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
return clarifyExpiredLine()
log.Printf("voice: clarify — %d parked question(s) expired, telling him and routing the words fresh", n)
return clarifyExpiredLine(n)
}
// withNotice glues the expiry notice in front of this turn's reply. One turn
@@ -121,6 +155,48 @@ func withNotice(notice, reply string) string {
return notice + " " + reply
}
// withResumed puts the resumed question on the END of this turn's reply, where
// withNotice puts the expiry notice on the front (Vikunja #561).
//
// The order is the owner's: "в Риме сейчас ..., на какое время поставить
// напоминание?" — answer first, then the open question. A question in front of
// its own answer would read as ignoring what he asked.
//
// A statement's full stop is folded into a comma, so the two acts read as one
// sentence — that is the owner's own punctuation, "в Риме сейчас ..., на какое
// время поставить напоминание?". An answer that is ITSELF a question keeps its
// mark and the resume starts a new sentence: she sometimes answers a side query
// by asking him to say it again, and "переформулировать?, на какое время" folds
// two questions into one unreadable line.
//
// A resume with no answer in front of it is just the question.
func withResumed(reply, resumed string) string {
if resumed == "" {
return reply
}
reply = strings.TrimSpace(reply)
if reply == "" {
return resumed
}
if strings.HasSuffix(reply, "?") {
return reply + " " + resumed
}
if trimmed := strings.TrimRight(reply, ".!"); trimmed != "" {
reply = trimmed
}
return reply + ", " + lowerFirst(resumed)
}
// lowerFirst lowercases the opening rune, so a deck line written as a standalone
// sentence reads as the second half of one. Only the first rune: "На какое
// время" must become "на какое время" and nothing else in it may move.
func lowerFirst(s string) string {
for i, r := range s {
return string(unicode.ToLower(r)) + s[i+utf8.RuneLen(r):]
}
return s
}
// missingFor returns the slots a decision still needs, most important first.
// Empty ⇒ there is nothing identifiable to ask about.
func missingFor(dec router.Decision) []dialogue.Slot {
@@ -161,7 +237,7 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
log.Printf("voice: clarify — act %q matched no capability; saying so instead of asking", dec.Utterance)
return actNotRecognized, true
}
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
q := &dialogue.PendingQuestion{
Intent: dialogue.Intent(dec.Intent),
Slots: toDialogueSlots(dec.Slots),
Missing: []dialogue.Slot{slot},
@@ -170,7 +246,33 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
TTL: clarifyTTL,
Attempts: 1, // this ask
MaxAttempts: h.clarifyMaxAttempts,
})
}
// Push, not Put, when this turn suspended a flow (Vikunja #561): the side
// query needs clarifying of ITS own, and Put would replace the top of the
// stack — which is the very question the side query was allowed to interrupt
// rather than kill. Push keeps both.
//
// Push returns whatever the depth bound forced out, and that one has to be
// spoken: MaxStackDepth is a promise that every level she keeps is a level
// she can name when it dies. It is glued in front, like every other notice
// about something let go.
rt := turnRouteFrom(ctx)
if rt != nil && rt.suspended {
if evicted := h.clarifyStore.Push(dialogueIDOf(ctx), q); evicted != nil {
log.Printf("voice: clarify — stack full at %d, letting go of the request behind %q", dialogue.MaxStackDepth, evicted.Utterance)
rt.dropped = withNotice(rt.dropped, clarifyDropped)
}
// One question per breath still holds. The side query turned out to need
// a question of its own, so THAT is the one she asks; resuming as well
// would put two questions in one reply, which is the interrogation
// askRemainingGap already refuses to run. The suspended flow keeps its
// place underneath and is not lost — if it is never reached it dies on
// the TTL, and the expiry notice (now plural-aware) says so.
rt.resume = ""
log.Printf("voice: clarify — asked about %s for intent=%s, stacked on a suspended flow", slot, dec.Intent)
return question, true
}
h.clarifyStore.Put(dialogueIDOf(ctx), q)
log.Printf("voice: clarify — asked about %s for intent=%s", slot, dec.Intent)
return question, true
}
@@ -180,10 +282,17 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
// self-reference ("отменила"), as everywhere.
const clarifyCancelled = "Хорошо, отменила."
// clarifyDropped — he asked for something else instead, so the parked request
// clarifyDropped — he asked for something ELSE instead, so the parked request
// is gone. Glued in front of the answer to what he actually asked, because
// nothing may be dropped in silence. V-561 suspends and resumes it instead of
// letting it go, and this line goes away with it.
// nothing may be dropped in silence.
//
// Only new_request and cancel reach this since V-561. A side query used to as
// well, and the owner rejected it on sight: he asks about the weather in the
// middle of setting a reminder, and hearing "прошлую просьбу отпускаю" tells him
// a thing he did not ask to lose has been lost. It had not been — there was
// simply nowhere to put it. Now there is (ClarifyStore's stack), so a side query
// suspends and resumes, and apologising for a drop that did not happen is worse
// than saying nothing.
const clarifyDropped = "Прошлую просьбу отпускаю."
// resolveClarifyAnswer reads an utterance against the parked question and
@@ -227,7 +336,19 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
case roleCancel:
h.clarifyStore.Delete(dialogueIDOf(ctx))
return clarifyCancelled, true
case roleSideQuery, roleNewRequest:
case roleSideQuery:
// He asked something of his own WITHOUT leaving the flow. The question
// stays exactly where it is — same slot, same attempt, same parked
// utterance — and these words go on to be answered as themselves. The
// resumed question is then glued onto the back of that answer, so one
// reply carries both acts (Vikunja #561).
//
// No attempt is spent. He answered the side query, not the parked
// question, and charging a retry for a turn that was never an answer is
// the V-554 shape.
h.noteSuspended(ctx, q)
return "", false
case roleNewRequest:
// He moved on. A parked question used to swallow whatever came next, so
// one act she could not fulfil ate the following three turns (Vikunja
// #554) and a world question set a reminder for a time nobody asked for
@@ -283,6 +404,35 @@ func (h *reactiveHandler) noteDropped(ctx context.Context) {
}
}
// noteSuspended keeps the parked question alive across a side query and records
// the words that bring it back, so runTurn can put them after this turn's answer
// (Vikunja #561).
//
// Two things happen to the question and neither is an attempt. Its clock is
// restarted, because she is about to ask it again and the 90s TTL measures the
// pause since she last spoke it — leaving Asked at the original ask would let a
// flow he is actively working through die of a wait he did not take. And the
// stack is left exactly as it is: the question is already on top, so suspending
// it is not a write.
//
// A slot with no resumed wording (clarifyResumedFor says so) resumes nothing and
// says nothing. She must not claim to be holding a question she cannot re-ask.
func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.PendingQuestion) {
rt := turnRouteFrom(ctx)
if rt == nil || len(q.Missing) == 0 {
return
}
question, ok := clarifyResumedFor(q.Missing[0])
if !ok {
return
}
q.Asked = h.now()
h.clarifyStore.Put(dialogueIDOf(ctx), q)
rt.resume = question
rt.suspended = true
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply", q.Missing[0])
}
// foldAnswerIntoUtterance appends an answered subject to the original words,
// unless they already carry it. "напомни" + "позвонить маме" reads as the
// request he would have made in one breath. Nothing is appended when the
+10 -2
View File
@@ -581,8 +581,16 @@ func TestClarifyStepsAsideForItsOwnRequest(t *testing.T) {
if reply, handled := h.resolveClarifyAnswer(ctx, "кто изобрёл телефон"); handled {
t.Fatalf("a world question must route as itself, got %q", reply)
}
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
t.Error("the parked question must be dropped, not left to eat the turn after this one")
// Not eating the turn is `handled == false` above, and that is the whole of
// #554. Since V-561 the question also SURVIVES it: a side query suspends the
// flow rather than ending it, so the reminder is still there and still on the
// attempt it was parked with.
q := h.clarifyStore.Get(voiceDialogueID, h.now())
if q == nil {
t.Fatal("a side query must suspend the parked question, not drop it")
}
if q.Attempts != 1 {
t.Errorf("a turn that was never an answer spent an attempt: %d, want 1", q.Attempts)
}
}
+32
View File
@@ -50,6 +50,38 @@ var clarifyQuestionVariants = map[dialogue.Slot][]string{
},
}
// clarifyResumedVariants — the wording for a question coming BACK after a side
// query took the turn away from it (Vikunja #561).
//
// It is not the first question again. "Когда?" works in the same breath as
// "напомни позвонить маме", because the thing it is about was just said. After
// a turn about the weather in Rome it does not: he has been thinking about
// something else, and a bare "Когда?" asks him to remember what she is holding.
// So the resumed form names the request — "напоминание", "заметка" — and the
// first form stays short.
//
// One wording per slot, not a rotation and not an attempt ladder. A resume does
// not spend an attempt (that is the point of suspending rather than re-asking),
// so there is no attempt number to vary on, and this line is heard once per
// interruption rather than repeatedly.
//
// Persona holds: infinitive, so no gender agreement, "ты" nowhere needed, no pet
// names.
var clarifyResumedVariants = map[dialogue.Slot]string{
dialogue.SlotTime: "На какое время поставить напоминание?",
dialogue.SlotText: "Так о чём напомнить?",
dialogue.SlotKey: "Так что записать?",
dialogue.SlotFn: "Так какое действие выполнить?",
}
// clarifyResumedFor gives the resumed wording for a slot. ("", false) when the
// slot has none, and the caller then resumes nothing rather than inventing a
// question — a flow it cannot re-ask is one it must not claim to be holding.
func clarifyResumedFor(slot dialogue.Slot) (string, bool) {
q, ok := clarifyResumedVariants[slot]
return q, ok
}
// actNotRecognized is what an act she cannot run gets (Vikunja #556).
//
// The deck used to ask "Что сделать?" instead. That question has no answer he
+16 -10
View File
@@ -452,13 +452,16 @@ func dialogueTraces() []trace {
// still standing, on the same attempt — a side query is not a failed
// answer and must not spend a retry.
//
// Unskipping this needs more than V-561. "на 9" and "на завтра" are not
// read by StubDateTimeParser, which is what the offline floor runs, so
// the row below it is the same shape in words the floor can parse and is
// the one to watch first.
// Unskipping this needs more than V-561, and V-561 landing did not change
// that. The suspend and resume it asked for is done — the row below is
// the same shape in words the floor can parse and is green. What is left
// here is the parser: StubDateTimeParser does not read "на 9" or "на
// завтра", so turn 3 lands as an answer that filled nothing and spends a
// retry, which is what this row now fails on. V-562 and V-543 own the
// ambiguous hour and the day correction behind those two words.
{
name: "the owner's transcript from V-561",
skip: "V-561: a parked question is not suspended for a side query and never resumes",
skip: "V-543/V-562: the floor's date parser reads neither «на 9» nor «на завтра»",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
@@ -470,13 +473,16 @@ func dialogueTraces() []trace {
},
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-08-01 09:00"}}},
},
// The same shape said in words StubDateTimeParser reads, so this row
// turns green on V-561 alone. Same three claims: Rome is answered, the
// question survives the side query on the same attempt, and the answer
// after it completes the reminder he actually asked for.
// The same shape said in words StubDateTimeParser reads. GREEN since
// V-561. Same three claims: Rome is answered, the question survives the
// side query on the same attempt, and the answer after it completes the
// reminder he actually asked for.
//
// It sits under the "fail today" header because the row above it still
// does. Do not re-skip it to tidy that up: this is the owner's
// acceptance test in the only words the offline floor can read.
{
name: "nested question: a parked question, then one of his own",
skip: "V-561: a side query drops the parked question instead of suspending it",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
+23 -6
View File
@@ -167,8 +167,12 @@ func TestTurnRoleNamesACorrection(t *testing.T) {
// TestRomeIsAnsweredAndTheReminderIsNotInvented — the measured failure of
// 2026-08-05, end to end through the real cascade. "напомни позвонить маме"
// parks the time question; the weather question that follows must not become
// its answer, must not create a reminder for a time nobody asked for, and must
// not be dropped in silence.
// its answer and must not create a reminder for a time nobody asked for.
//
// V-560 got that far by DROPPING the parked request and saying so, and the
// owner rejected the notice on sight: he did not ask to lose the reminder. So
// the contract here is V-561's — the flow is suspended, this turn's reply ends
// with the question coming back, and nothing says anything was let go.
func TestRomeIsAnsweredAndTheReminderIsNotInvented(t *testing.T) {
ctx := context.Background()
h, st := newRoutingClarifyHandler(t)
@@ -180,14 +184,27 @@ func TestRomeIsAnsweredAndTheReminderIsNotInvented(t *testing.T) {
if strings.Contains(reply, "напомню") {
t.Fatalf("the question was eaten as the reminder's time again: %q", reply)
}
if !strings.HasPrefix(reply, clarifyDropped) {
t.Fatalf("the parked request died without a word: %q", reply)
if strings.Contains(reply, clarifyDropped) {
t.Fatalf("a side query suspends the flow; nothing was dropped, so nothing may say so: %q", reply)
}
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
if !strings.HasSuffix(reply, resumed) {
t.Fatalf("the reply must end with the resumed question %q, got %q", resumed, reply)
}
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
t.Fatalf("a reminder was invented for a time nobody asked for: %v err=%v", reminders, err)
}
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) != nil {
t.Fatal("the parked question must be gone, not left to eat the next turn")
// Still parked, and still on its first attempt: he answered the side query,
// not this question, so no retry may have been spent on it.
q := h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now())
if q == nil {
t.Fatal("the parked question was dropped instead of suspended")
}
if q.Attempts != 1 {
t.Fatalf("the side query spent a clarify attempt: attempts = %d, want 1", q.Attempts)
}
if !strings.Contains(q.Utterance, "маме") {
t.Fatalf("the suspended request lost what it was about: %q", q.Utterance)
}
}
+11
View File
@@ -31,6 +31,17 @@ type turnRoute struct {
// dropped — what she let go of this turn and must say out loud. A parked
// request that dies without a word leaves him thinking it landed.
dropped string
// resume — the parked question, re-worded, to put AFTER this turn's answer
// (Vikunja #561). A side query does not end the flow it interrupted, so the
// reply carries two acts: the answer he asked for, then the question he
// still owes her. Empty ⇒ nothing was suspended.
resume string
// suspended — a flow is parked underneath this turn. askClarify reads it to
// decide between Put (replace the top) and Push (keep the flow and stack the
// new question on it), because a side query that needs clarifying of its own
// must not overwrite the thing it interrupted.
suspended bool
}
type turnRouteKey struct{}
+9 -1
View File
@@ -255,7 +255,7 @@ const (
// path wraps it in stt/tts, the text path returns it as-is.
//
// The ordering is load-bearing — see the step comments.
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) string {
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) (reply string) {
// 0. the decision record (V-564). Installed here rather than in the IPC
// entry point, so the mic, telegram and the web all leave the same trail —
// a record only the web produced would be missing exactly the turns that
@@ -311,6 +311,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// with — carried on the same notice, so every exit below keeps it.
expiredNotice = withNotice(expiredNotice, rt.dropped)
// 3b. and if it SUSPENDED a request instead of letting it go, the question
// comes back on the end of whatever these words are answered with (Vikunja
// #561). A deferred append rather than a call at each exit: there are eight
// returns between here and the replier, and the flow has to survive all of
// them — one that forgot would be a request parked for ever, waiting for an
// answer to a question he never heard asked.
defer func() { reply = withResumed(reply, rt.resume) }()
// 4. quiet-hours toggle — keyword match, not classifier-dependent.
// "тихий режим" / "quiet on" would route through the classifier
// unreliably (it's a command, not a free-form query), so we match it
+13 -10
View File
@@ -207,23 +207,26 @@ func (s *ClarifyStore) Depth(id string) int {
return len(s.stacks[id])
}
// TakeExpired reports whether a question was parked here but its TTL ran out,
// and drops it. Get drops such a question silently, which leaves the user
// thinking his request is still alive — the caller uses this to tell him it is
// gone before treating his words as a fresh utterance.
// TakeExpired reports HOW MANY parked questions were dropped because the TTL
// ran out, and drops them. 0 ⇒ nothing was parked, or what was parked is still
// live. Get drops such a question silently, which leaves the user thinking his
// request is still alive — the caller uses this to tell him it is gone before
// treating his words as a fresh utterance.
//
// It looks at the top only, and drops the whole stack when that one is dead: one
// notice is what a reply can carry, and anything parked under a question that
// timed out has been waiting at least as long.
func (s *ClarifyStore) TakeExpired(id string, now time.Time) bool {
// It looks at the top only, and drops the whole stack when that one is dead:
// anything parked under a question that timed out has been waiting at least as
// long. The COUNT rather than a bool since V-561, because the stack can now
// hold two — the flow and the side query that suspended it — and a notice
// saying "прошлую просьбу" when two died is a lie about the count.
func (s *ClarifyStore) TakeExpired(id string, now time.Time) int {
s.mu.Lock()
defer s.mu.Unlock()
stack := s.stacks[id]
if len(stack) == 0 || !stack[len(stack)-1].IsExpired(now) {
return false
return 0
}
delete(s.stacks, id)
return true
return len(stack)
}
// Delete drops every question parked for this id. The old single-slot Delete
+8 -8
View File
@@ -64,21 +64,21 @@ func TestClarifyStoreGetPutDelete(t *testing.T) {
// whose TTL ran out.
func TestClarifyStoreTakeExpired(t *testing.T) {
s := NewClarifyStore(time.Minute)
if s.TakeExpired("voice", base) {
t.Fatal("nothing parked ⇒ nothing expired")
if n := s.TakeExpired("voice", base); n != 0 {
t.Fatalf("nothing parked ⇒ nothing expired, got %d", n)
}
s.Put("voice", &PendingQuestion{Missing: []Slot{SlotTime}, Asked: base, TTL: time.Minute})
if s.TakeExpired("voice", base.Add(30*time.Second)) {
t.Fatal("a live question must not report as expired")
if n := s.TakeExpired("voice", base.Add(30*time.Second)); n != 0 {
t.Fatalf("a live question must not report as expired, got %d", n)
}
if s.Get("voice", base.Add(30*time.Second)) == nil {
t.Fatal("a live question must survive TakeExpired")
}
if !s.TakeExpired("voice", base.Add(2*time.Minute)) {
t.Fatal("a stale question must report as expired")
if n := s.TakeExpired("voice", base.Add(2*time.Minute)); n != 1 {
t.Fatalf("a stale question must report as one expired, got %d", n)
}
if s.TakeExpired("voice", base.Add(2*time.Minute)) {
t.Fatal("TakeExpired must drop the question, so the second call is false")
if n := s.TakeExpired("voice", base.Add(2*time.Minute)); n != 0 {
t.Fatalf("TakeExpired must drop the question, so the second call is 0, got %d", n)
}
}
+6 -4
View File
@@ -116,14 +116,16 @@ func TestStackExpiryDropsTheStackAndIsReported(t *testing.T) {
s.Push("voice", parked("напомни", pendingBase))
s.Push("voice", parked("погода", pendingBase))
if !s.TakeExpired("voice", late) {
t.Error("TakeExpired did not report the timed-out exchange")
// Two died, and the count says two: the notice that reports this has a
// plural wording since V-561, and it is chosen off this number.
if n := s.TakeExpired("voice", late); n != 2 {
t.Errorf("TakeExpired reported %d timed-out questions, want 2", n)
}
if s.Depth("voice") != 0 {
t.Error("TakeExpired left entries behind")
}
if s.TakeExpired("voice", late) {
t.Error("TakeExpired reported twice")
if n := s.TakeExpired("voice", late); n != 0 {
t.Errorf("TakeExpired reported twice: %d", n)
}
// Pop of an expired top yields nothing rather than a dead action.
s.Push("voice", parked("напомни", pendingBase))