Rooms and prefs come back with you
`store.nim` was ported with the rest of the core and then read by nothing: rooms started empty every run, unread markers started over, and the display toggles were answered again on every launch. The session half got wired up with the broker flow. This is the other two thirds. Rooms come back as empty, unjoined buffers — the list is what is worth keeping and the lines in it come from the server. The marker beside each name is the point: without one every replayed line is new and a room returns with its whole history unread. A record that has no marker, from an older frq or one that lost it, is caught up to now rather than counted from the beginning — the kinder of the two wrong answers. At 001 the saved list is what gets rejoined. The server forgets: it has claimed this client was in rooms it was not and left out ones it was, so the file is the authority and a room is gone when the reader closes it and not before. DMs are in the list but are not joined — there is nothing to be in. `JOIN #test` stays for a first run with nothing saved. Writing is throttled to five seconds, because the things that move a marker happen in bursts and the alternative is a file write per line. A late write costs the handful of lines since the last one, shown unread again — the marker never claims to have read more than it has. Leaving a room and disconnecting force it, being the moments there may not be a next chance. A digest of exactly the saved fields guards the write: `drain` calls this every frame, so the throttle alone would rewrite the same file every five seconds for as long as the app is open. Prefs are the three toggles that are the reader's answer rather than the moment's: join/part, the member list, the room list. The overview is deliberately not among them. The tests point `XDG_CONFIG_HOME` at a directory of their own, set before `frq/store` is touched — a suite that read the config of whoever ran it would both lie about the result and cost them their room list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a4be36c parent: bdd2c3c modified
nim/src/frq/reducer.nim +133 -10 | @@ -35,6 +35,10 @@ proc setError(msg: string) = | ||
| 35 | 35 | app.error = msg |
| 36 | 36 | app.hasError = true |
| 37 | 37 | |
| 38 | +proc rememberRooms(force = false) | |
| 39 | + ## Declared here because `openRoom` is above it and calls it — the file is | |
| 40 | + ## ordered by what the reader does, not by what calls what. | |
| 41 | + | |
| 38 | 42 | proc send(line: string) = |
| 39 | 43 | trace("out", line) |
| 40 | 44 | tr.send(line) |
| @@ -49,16 +53,100 @@ proc openRoom(name: string) = | ||
| 49 | 53 | app.atPresent = true |
| 50 | 54 | # Opening a room is reading it: the marker moves to the newest line here. |
| 51 | 55 | app.rooms[name] = app.rooms[name].markRead |
| 56 | + # Both of the things the file keeps just changed — where this room sits in | |
| 57 | + # the list, and how much of it has been read. Throttled, so a reader | |
| 58 | + # flicking through five rooms writes once. | |
| 59 | + rememberRooms() | |
| 60 | + | |
| 61 | +proc restoreRooms() = | |
| 62 | + ## The rooms of earlier runs, in the order they were last used. | |
| 63 | + ## | |
| 64 | + ## Empty buffers, not memberships: the list of rooms is the part worth | |
| 65 | + ## keeping and the messages in them come from the server. What makes a | |
| 66 | + ## returning backlog readable is the marker beside each name — without one | |
| 67 | + ## every replayed line is new and every room comes back with its whole | |
| 68 | + ## history unread. | |
| 69 | + ## | |
| 70 | + ## Inserted oldest first, so the table's own order matches the list the | |
| 71 | + ## reader last saw; `accessed` comes back from the file, so anything that | |
| 72 | + ## sorts agrees with it. A record with no marker at all — an older frq's | |
| 73 | + ## file, or one that lost it — is caught up to now rather than counted from | |
| 74 | + ## the beginning, which is the kinder of the two wrong answers. | |
| 75 | + let saved = loadRooms() | |
| 76 | + if saved.len == 0: return | |
| 77 | + for i in countdown(saved.high, 0): | |
| 78 | + let r = saved[i] | |
| 79 | + if app.rooms.hasKey(r.name): continue | |
| 80 | + var ch = initRoom(r.name) | |
| 81 | + ch.accessed = r.accessed | |
| 82 | + ch.lastReadId = r.lastReadId | |
| 83 | + ch.lastReadAt = if r.lastReadAt > 0: r.lastReadAt else: nowMs() | |
| 84 | + app.rooms[r.name] = ch | |
| 85 | + trace("store", $saved.len & " rooms remembered") | |
| 86 | + | |
| 87 | +proc restorePrefs() = | |
| 88 | + ## The display toggles. Three booleans, and every one of them is the | |
| 89 | + ## reader's answer to a question this client has no business re-asking on | |
| 90 | + ## every run — whether the comings and goings are worth seeing, whether the | |
| 91 | + ## member list is up, whether the room list is out of the way. | |
| 92 | + let prefs = loadPrefs() | |
| 93 | + app.hideJoinPart = prefs.getOrDefault("hideJoinPart", app.hideJoinPart) | |
| 94 | + app.showUsers = prefs.getOrDefault("showUsers", app.showUsers) | |
| 95 | + app.hideChatList = prefs.getOrDefault("hideChatList", app.hideChatList) | |
| 96 | + | |
| 97 | +proc rememberPrefs() = | |
| 98 | + discard savePrefs({"hideJoinPart": app.hideJoinPart, | |
| 99 | + "showUsers": app.showUsers, | |
| 100 | + "hideChatList": app.hideChatList}.toTable) | |
| 101 | + | |
| 102 | +var | |
| 103 | + roomsSavedAt: int64 = 0 | |
| 104 | + roomsWritten: string | |
| 105 | + | |
| 106 | +proc roomsDigest(): string = | |
| 107 | + ## Exactly the fields the file holds, so "nothing changed" means nothing | |
| 108 | + ## the file would show changed. A message arriving in a room nobody is | |
| 109 | + ## looking at moves `lastActivity`, which is not saved and must not cost a | |
| 110 | + ## write. | |
| 111 | + for name, r in app.rooms: | |
| 112 | + result.add name & "\x1f" & $r.accessed & "\x1f" & r.lastReadId & | |
| 113 | + "\x1f" & $r.lastReadAt & "\x1e" | |
| 114 | + | |
| 115 | +proc rememberRooms(force = false) = | |
| 116 | + ## Write the room list out, at most every five seconds. | |
| 117 | + ## | |
| 118 | + ## Throttled because the things that move a marker — opening a room, | |
| 119 | + ## reading one, a line arriving in the one you are looking at — happen in | |
| 120 | + ## bursts, and a file write per line is a file write per line. | |
| 121 | + ## | |
| 122 | + ## A late write costs at most the handful of lines that arrived since the | |
| 123 | + ## last one, shown unread again next run. That is the right way round: the | |
| 124 | + ## marker never claims to have read more than it has. `force` is for the | |
| 125 | + ## moments there may not be a next chance — leaving a room, disconnecting. | |
| 126 | + let now = nowMs() | |
| 127 | + if not force and now - roomsSavedAt < 5000: return | |
| 128 | + # `drain` calls this on every frame, so the throttle alone would write the | |
| 129 | + # same file every five seconds for as long as the app is open. | |
| 130 | + let digest = roomsDigest() | |
| 131 | + if digest == roomsWritten: return | |
| 132 | + roomsSavedAt = now | |
| 133 | + roomsWritten = digest | |
| 134 | + discard saveRooms(app.rooms) | |
| 52 | 135 | |
| 53 | 136 | proc restore*() = |
| 54 | 137 | ## What a previous run left on disk, back in the state. |
| 55 | 138 | ## |
| 56 | - ## Only the sign-in: a broker token is what saves the reader a login page, | |
| 57 | - ## and the mode goes with it because a remembered session is not much use | |
| 58 | - ## sitting behind the Guest tab. The nick and handle come along so the | |
| 59 | - ## screen says who it is about before the broker is asked. | |
| 139 | + ## Three files, and they answer three different questions: who you are, what | |
| 140 | + ## you were in, and how you like it. Any of them may be missing, and a run | |
| 141 | + ## with none of them is a first run rather than an error. | |
| 142 | + restoreRooms() | |
| 143 | + restorePrefs() | |
| 60 | 144 | let (saved, had) = loadSession() |
| 61 | 145 | if not had: return |
| 146 | + # The broker token is what saves the reader a login page, and the mode goes | |
| 147 | + # with it: a remembered session is not much use sitting behind the Guest | |
| 148 | + # tab. The nick and handle come along so the screen says who it is about | |
| 149 | + # before the broker is asked. | |
| 62 | 150 | app.brokerToken = saved.brokerToken |
| 63 | 151 | app.authMode = amBluesky |
| 64 | 152 | if saved.handle.len > 0: app.formHandle = saved.handle |
| @@ -232,6 +320,7 @@ proc dispatch*(event: JsonNode) = | ||
| 232 | 320 | of "connect": connectNow() |
| 233 | 321 | |
| 234 | 322 | of "cancel", "disconnect": |
| 323 | + rememberRooms(force = true) | |
| 235 | 324 | # A browser wait is part of connecting, so Cancel ends it too — otherwise |
| 236 | 325 | # a tab finished ten minutes later would sign in behind the reader. |
| 237 | 326 | oa.cancel() |
| @@ -269,6 +358,9 @@ proc dispatch*(event: JsonNode) = | ||
| 269 | 358 | if app.rooms.hasKey(arg): |
| 270 | 359 | if not dm(arg): send("PART " & arg) |
| 271 | 360 | app.rooms.del(arg) |
| 361 | + # Forced: a room removed and not written out comes back on the next run | |
| 362 | + # as one the reader has already closed. | |
| 363 | + rememberRooms(force = true) | |
| 272 | 364 | if app.current == arg: |
| 273 | 365 | app.current = "" |
| 274 | 366 | app.screen = scChats |
| @@ -291,10 +383,23 @@ proc dispatch*(event: JsonNode) = | ||
| 291 | 383 | of "search.clear": app.search = "" |
| 292 | 384 | |
| 293 | 385 | # ---------------------------------------------------------- chat chrome |
| 294 | - of "chat-list.toggle": app.hideChatList = not app.hideChatList | |
| 295 | - of "users.toggle": app.showUsers = not app.showUsers | |
| 296 | - of "overview.toggle": app.overview = not app.overview | |
| 297 | - of "join-part.toggle": app.hideJoinPart = not app.hideJoinPart | |
| 386 | + # The three that outlive the run are written as they are pressed. There is | |
| 387 | + # no Save on this screen and no moment that is obviously the last one — the | |
| 388 | + # window closes when it closes. | |
| 389 | + of "chat-list.toggle": | |
| 390 | + app.hideChatList = not app.hideChatList | |
| 391 | + rememberPrefs() | |
| 392 | + of "users.toggle": | |
| 393 | + app.showUsers = not app.showUsers | |
| 394 | + rememberPrefs() | |
| 395 | + of "overview.toggle": | |
| 396 | + # Not kept: the overview is a way of looking at the moment you are in | |
| 397 | + # rather than a preference, and a client that reopened into it would be | |
| 398 | + # answering a question nobody asked twice. | |
| 399 | + app.overview = not app.overview | |
| 400 | + of "join-part.toggle": | |
| 401 | + app.hideJoinPart = not app.hideJoinPart | |
| 402 | + rememberPrefs() | |
| 298 | 403 | of "jump.present": |
| 299 | 404 | app.atPresent = true |
| 300 | 405 | app.jumpTick += 1 |
| @@ -520,7 +625,22 @@ proc drain*() = | ||
| 520 | 625 | app.connecting = false |
| 521 | 626 | app.status = "Connected as " & app.formNick |
| 522 | 627 | app.screen = scChats |
| 523 | - send("JOIN #test") | |
| 628 | + # What the file says we were in, we ask to be in again. The server | |
| 629 | + # forgets: it has told this client it is in rooms it is not and left out | |
| 630 | + # ones it is, so the saved list is the authority and a room is gone when | |
| 631 | + # the reader closes it and not before. | |
| 632 | + # | |
| 633 | + # DMs are not joined — there is nothing to be in — but they are in the | |
| 634 | + # list and come back with their markers all the same. | |
| 635 | + var asked = 0 | |
| 636 | + for name, _ in app.rooms: | |
| 637 | + if not dm(name): | |
| 638 | + send("JOIN " & name) | |
| 639 | + asked.inc | |
| 640 | + # A first run has nothing saved. `#test` is where this client has always | |
| 641 | + # landed with no list of its own, and stays that until the server's own | |
| 642 | + # JOINs are what fills an empty one. | |
| 643 | + if asked == 0: send("JOIN #test") | |
| 524 | 644 | |
| 525 | 645 | of "PRIVMSG": |
| 526 | 646 | if p.params.len >= 2: |
| @@ -628,4 +748,7 @@ proc drain*() = | ||
| 628 | 748 | else: |
| 629 | 749 | trace("skip", p.command & " " & $p.params) |
| 630 | 750 | |
| 631 | - | |
| 751 | + # A line arriving moves the marker in the room being looked at, and closing | |
| 752 | + # the window is not a moment this client gets told about — so the saving | |
| 753 | + # happens as it goes, throttled, rather than at an end that may never come. | |
| 754 | + rememberRooms() | |
| @@ -35,6 +35,10 @@ proc setError(msg: string) = | |||
| 35 | app.error = msg | 35 | app.error = msg |
| 36 | app.hasError = true | 36 | app.hasError = true |
| 37 | 37 | ||
| 38 | +proc rememberRooms(force = false) | ||
| 39 | + ## Declared here because `openRoom` is above it and calls it — the file is | ||
| 40 | + ## ordered by what the reader does, not by what calls what. | ||
| 41 | + | ||
| 38 | proc send(line: string) = | 42 | proc send(line: string) = |
| 39 | trace("out", line) | 43 | trace("out", line) |
| 40 | tr.send(line) | 44 | tr.send(line) |
| @@ -49,16 +53,100 @@ proc openRoom(name: string) = | |||
| 49 | app.atPresent = true | 53 | app.atPresent = true |
| 50 | # Opening a room is reading it: the marker moves to the newest line here. | 54 | # Opening a room is reading it: the marker moves to the newest line here. |
| 51 | app.rooms[name] = app.rooms[name].markRead | 55 | app.rooms[name] = app.rooms[name].markRead |
| 56 | + # Both of the things the file keeps just changed — where this room sits in | ||
| 57 | + # the list, and how much of it has been read. Throttled, so a reader | ||
| 58 | + # flicking through five rooms writes once. | ||
| 59 | + rememberRooms() | ||
| 60 | + | ||
| 61 | +proc restoreRooms() = | ||
| 62 | + ## The rooms of earlier runs, in the order they were last used. | ||
| 63 | + ## | ||
| 64 | + ## Empty buffers, not memberships: the list of rooms is the part worth | ||
| 65 | + ## keeping and the messages in them come from the server. What makes a | ||
| 66 | + ## returning backlog readable is the marker beside each name — without one | ||
| 67 | + ## every replayed line is new and every room comes back with its whole | ||
| 68 | + ## history unread. | ||
| 69 | + ## | ||
| 70 | + ## Inserted oldest first, so the table's own order matches the list the | ||
| 71 | + ## reader last saw; `accessed` comes back from the file, so anything that | ||
| 72 | + ## sorts agrees with it. A record with no marker at all — an older frq's | ||
| 73 | + ## file, or one that lost it — is caught up to now rather than counted from | ||
| 74 | + ## the beginning, which is the kinder of the two wrong answers. | ||
| 75 | + let saved = loadRooms() | ||
| 76 | + if saved.len == 0: return | ||
| 77 | + for i in countdown(saved.high, 0): | ||
| 78 | + let r = saved[i] | ||
| 79 | + if app.rooms.hasKey(r.name): continue | ||
| 80 | + var ch = initRoom(r.name) | ||
| 81 | + ch.accessed = r.accessed | ||
| 82 | + ch.lastReadId = r.lastReadId | ||
| 83 | + ch.lastReadAt = if r.lastReadAt > 0: r.lastReadAt else: nowMs() | ||
| 84 | + app.rooms[r.name] = ch | ||
| 85 | + trace("store", $saved.len & " rooms remembered") | ||
| 86 | + | ||
| 87 | +proc restorePrefs() = | ||
| 88 | + ## The display toggles. Three booleans, and every one of them is the | ||
| 89 | + ## reader's answer to a question this client has no business re-asking on | ||
| 90 | + ## every run — whether the comings and goings are worth seeing, whether the | ||
| 91 | + ## member list is up, whether the room list is out of the way. | ||
| 92 | + let prefs = loadPrefs() | ||
| 93 | + app.hideJoinPart = prefs.getOrDefault("hideJoinPart", app.hideJoinPart) | ||
| 94 | + app.showUsers = prefs.getOrDefault("showUsers", app.showUsers) | ||
| 95 | + app.hideChatList = prefs.getOrDefault("hideChatList", app.hideChatList) | ||
| 96 | + | ||
| 97 | +proc rememberPrefs() = | ||
| 98 | + discard savePrefs({"hideJoinPart": app.hideJoinPart, | ||
| 99 | + "showUsers": app.showUsers, | ||
| 100 | + "hideChatList": app.hideChatList}.toTable) | ||
| 101 | + | ||
| 102 | +var | ||
| 103 | + roomsSavedAt: int64 = 0 | ||
| 104 | + roomsWritten: string | ||
| 105 | + | ||
| 106 | +proc roomsDigest(): string = | ||
| 107 | + ## Exactly the fields the file holds, so "nothing changed" means nothing | ||
| 108 | + ## the file would show changed. A message arriving in a room nobody is | ||
| 109 | + ## looking at moves `lastActivity`, which is not saved and must not cost a | ||
| 110 | + ## write. | ||
| 111 | + for name, r in app.rooms: | ||
| 112 | + result.add name & "\x1f" & $r.accessed & "\x1f" & r.lastReadId & | ||
| 113 | + "\x1f" & $r.lastReadAt & "\x1e" | ||
| 114 | + | ||
| 115 | +proc rememberRooms(force = false) = | ||
| 116 | + ## Write the room list out, at most every five seconds. | ||
| 117 | + ## | ||
| 118 | + ## Throttled because the things that move a marker — opening a room, | ||
| 119 | + ## reading one, a line arriving in the one you are looking at — happen in | ||
| 120 | + ## bursts, and a file write per line is a file write per line. | ||
| 121 | + ## | ||
| 122 | + ## A late write costs at most the handful of lines that arrived since the | ||
| 123 | + ## last one, shown unread again next run. That is the right way round: the | ||
| 124 | + ## marker never claims to have read more than it has. `force` is for the | ||
| 125 | + ## moments there may not be a next chance — leaving a room, disconnecting. | ||
| 126 | + let now = nowMs() | ||
| 127 | + if not force and now - roomsSavedAt < 5000: return | ||
| 128 | + # `drain` calls this on every frame, so the throttle alone would write the | ||
| 129 | + # same file every five seconds for as long as the app is open. | ||
| 130 | + let digest = roomsDigest() | ||
| 131 | + if digest == roomsWritten: return | ||
| 132 | + roomsSavedAt = now | ||
| 133 | + roomsWritten = digest | ||
| 134 | + discard saveRooms(app.rooms) | ||
| 52 | 135 | ||
| 53 | proc restore*() = | 136 | proc restore*() = |
| 54 | ## What a previous run left on disk, back in the state. | 137 | ## What a previous run left on disk, back in the state. |
| 55 | ## | 138 | ## |
| 56 | - ## Only the sign-in: a broker token is what saves the reader a login page, | 139 | + ## Three files, and they answer three different questions: who you are, what |
| 57 | - ## and the mode goes with it because a remembered session is not much use | 140 | + ## you were in, and how you like it. Any of them may be missing, and a run |
| 58 | - ## sitting behind the Guest tab. The nick and handle come along so the | 141 | + ## with none of them is a first run rather than an error. |
| 59 | - ## screen says who it is about before the broker is asked. | 142 | + restoreRooms() |
| 143 | + restorePrefs() | ||
| 60 | let (saved, had) = loadSession() | 144 | let (saved, had) = loadSession() |
| 61 | if not had: return | 145 | if not had: return |
| 146 | + # The broker token is what saves the reader a login page, and the mode goes | ||
| 147 | + # with it: a remembered session is not much use sitting behind the Guest | ||
| 148 | + # tab. The nick and handle come along so the screen says who it is about | ||
| 149 | + # before the broker is asked. | ||
| 62 | app.brokerToken = saved.brokerToken | 150 | app.brokerToken = saved.brokerToken |
| 63 | app.authMode = amBluesky | 151 | app.authMode = amBluesky |
| 64 | if saved.handle.len > 0: app.formHandle = saved.handle | 152 | if saved.handle.len > 0: app.formHandle = saved.handle |
| @@ -232,6 +320,7 @@ proc dispatch*(event: JsonNode) = | |||
| 232 | of "connect": connectNow() | 320 | of "connect": connectNow() |
| 233 | 321 | ||
| 234 | of "cancel", "disconnect": | 322 | of "cancel", "disconnect": |
| 323 | + rememberRooms(force = true) | ||
| 235 | # A browser wait is part of connecting, so Cancel ends it too — otherwise | 324 | # A browser wait is part of connecting, so Cancel ends it too — otherwise |
| 236 | # a tab finished ten minutes later would sign in behind the reader. | 325 | # a tab finished ten minutes later would sign in behind the reader. |
| 237 | oa.cancel() | 326 | oa.cancel() |
| @@ -269,6 +358,9 @@ proc dispatch*(event: JsonNode) = | |||
| 269 | if app.rooms.hasKey(arg): | 358 | if app.rooms.hasKey(arg): |
| 270 | if not dm(arg): send("PART " & arg) | 359 | if not dm(arg): send("PART " & arg) |
| 271 | app.rooms.del(arg) | 360 | app.rooms.del(arg) |
| 361 | + # Forced: a room removed and not written out comes back on the next run | ||
| 362 | + # as one the reader has already closed. | ||
| 363 | + rememberRooms(force = true) | ||
| 272 | if app.current == arg: | 364 | if app.current == arg: |
| 273 | app.current = "" | 365 | app.current = "" |
| 274 | app.screen = scChats | 366 | app.screen = scChats |
| @@ -291,10 +383,23 @@ proc dispatch*(event: JsonNode) = | |||
| 291 | of "search.clear": app.search = "" | 383 | of "search.clear": app.search = "" |
| 292 | 384 | ||
| 293 | # ---------------------------------------------------------- chat chrome | 385 | # ---------------------------------------------------------- chat chrome |
| 294 | - of "chat-list.toggle": app.hideChatList = not app.hideChatList | 386 | + # The three that outlive the run are written as they are pressed. There is |
| 295 | - of "users.toggle": app.showUsers = not app.showUsers | 387 | + # no Save on this screen and no moment that is obviously the last one — the |
| 296 | - of "overview.toggle": app.overview = not app.overview | 388 | + # window closes when it closes. |
| 297 | - of "join-part.toggle": app.hideJoinPart = not app.hideJoinPart | 389 | + of "chat-list.toggle": |
| 390 | + app.hideChatList = not app.hideChatList | ||
| 391 | + rememberPrefs() | ||
| 392 | + of "users.toggle": | ||
| 393 | + app.showUsers = not app.showUsers | ||
| 394 | + rememberPrefs() | ||
| 395 | + of "overview.toggle": | ||
| 396 | + # Not kept: the overview is a way of looking at the moment you are in | ||
| 397 | + # rather than a preference, and a client that reopened into it would be | ||
| 398 | + # answering a question nobody asked twice. | ||
| 399 | + app.overview = not app.overview | ||
| 400 | + of "join-part.toggle": | ||
| 401 | + app.hideJoinPart = not app.hideJoinPart | ||
| 402 | + rememberPrefs() | ||
| 298 | of "jump.present": | 403 | of "jump.present": |
| 299 | app.atPresent = true | 404 | app.atPresent = true |
| 300 | app.jumpTick += 1 | 405 | app.jumpTick += 1 |
| @@ -520,7 +625,22 @@ proc drain*() = | |||
| 520 | app.connecting = false | 625 | app.connecting = false |
| 521 | app.status = "Connected as " & app.formNick | 626 | app.status = "Connected as " & app.formNick |
| 522 | app.screen = scChats | 627 | app.screen = scChats |
| 523 | - send("JOIN #test") | 628 | + # What the file says we were in, we ask to be in again. The server |
| 629 | + # forgets: it has told this client it is in rooms it is not and left out | ||
| 630 | + # ones it is, so the saved list is the authority and a room is gone when | ||
| 631 | + # the reader closes it and not before. | ||
| 632 | + # | ||
| 633 | + # DMs are not joined — there is nothing to be in — but they are in the | ||
| 634 | + # list and come back with their markers all the same. | ||
| 635 | + var asked = 0 | ||
| 636 | + for name, _ in app.rooms: | ||
| 637 | + if not dm(name): | ||
| 638 | + send("JOIN " & name) | ||
| 639 | + asked.inc | ||
| 640 | + # A first run has nothing saved. `#test` is where this client has always | ||
| 641 | + # landed with no list of its own, and stays that until the server's own | ||
| 642 | + # JOINs are what fills an empty one. | ||
| 643 | + if asked == 0: send("JOIN #test") | ||
| 524 | 644 | ||
| 525 | of "PRIVMSG": | 645 | of "PRIVMSG": |
| 526 | if p.params.len >= 2: | 646 | if p.params.len >= 2: |
| @@ -628,4 +748,7 @@ proc drain*() = | |||
| 628 | else: | 748 | else: |
| 629 | trace("skip", p.command & " " & $p.params) | 749 | trace("skip", p.command & " " & $p.params) |
| 630 | 750 | ||
| 631 | - | 751 | + # A line arriving moves the marker in the room being looked at, and closing |
| 752 | + # the window is not a moment this client gets told about — so the saving | ||
| 753 | + # happens as it goes, throttled, rather than at an end that may never come. | ||
| 754 | + rememberRooms() | ||
modified
nim/tests/tstore.nim +132 -118 | @@ -1,119 +1,133 @@ | ||
| 1 | -## What survives a restart. Every case runs against a real directory under a | |
| 2 | -## temporary XDG_CONFIG_HOME, because the thing being tested is files. | |
| 3 | - | |
| 4 | -import std/[os, tables, unittest] | |
| 5 | -import frq/[store, model] | |
| 6 | - | |
| 7 | -template withTempConfig(body: untyped) = | |
| 8 | - let dir = getTempDir() / "frq-test-store-" & $getCurrentProcessId() | |
| 9 | - removeDir(dir) | |
| 10 | - createDir(dir) | |
| 11 | - putEnv("XDG_CONFIG_HOME", dir) | |
| 12 | - defer: | |
| 13 | - delEnv("XDG_CONFIG_HOME") | |
| 14 | - removeDir(dir) | |
| 15 | - body | |
| 16 | - | |
| 17 | -suite "the session": | |
| 18 | - test "absent when nothing was saved": | |
| 19 | - withTempConfig: | |
| 20 | - check loadSession()[1] == false | |
| 21 | - | |
| 22 | - test "round-trips": | |
| 23 | - withTempConfig: | |
| 24 | - check saveSession(SavedSession(brokerToken: "tok", handle: "alice", | |
| 25 | - did: "did:plc:x", nick: "alice")) | |
| 26 | - let (s, ok) = loadSession() | |
| 27 | - check ok | |
| 28 | - check s.brokerToken == "tok" | |
| 29 | - check s.handle == "alice" | |
| 30 | - check s.did == "did:plc:x" | |
| 31 | - | |
| 32 | - test "one with no broker token is not a session": | |
| 33 | - # The token is the whole point of saving one. | |
| 34 | - withTempConfig: | |
| 35 | - check saveSession(SavedSession(handle: "alice")) | |
| 36 | - check loadSession()[1] == false | |
| 37 | - | |
| 38 | - test "a file that will not parse is treated as absent": | |
| 39 | - # A stale credential is not worth an error at startup. | |
| 40 | - withTempConfig: | |
| 41 | - createDir(configDir()) | |
| 42 | - writeFile(sessionFile(), "{ this is not json") | |
| 43 | - check loadSession()[1] == false | |
| 44 | - | |
| 1 | +## What survives a restart, and what deliberately does not. | |
| 2 | +## | |
| 3 | +## Every test here points `XDG_CONFIG_HOME` at a directory of its own, set | |
| 4 | +## before `frq/store` is touched at all: the alternative is a suite that reads | |
| 5 | +## and writes the config of whoever runs it, which would both lie about the | |
| 6 | +## result and cost them their room list. | |
| 7 | + | |
| 8 | +import std/[json, os, sets, tables, times, unittest] | |
| 9 | + | |
| 10 | +let sandbox = getTempDir() / "frq-tstore-" & $epochTime() | |
| 11 | +putEnv("XDG_CONFIG_HOME", sandbox) | |
| 12 | + | |
| 13 | +import frq/[cells, clock, model, rooms, store, reducer] | |
| 14 | + | |
| 15 | +proc clean() = | |
| 16 | + removeDir(sandbox) | |
| 17 | + app = initState() | |
| 18 | + | |
| 19 | +suite "the config directory": | |
| 20 | + test "is under XDG_CONFIG_HOME where there is one": | |
| 21 | + check configDir() == sandbox / "frq" | |
| 22 | + | |
| 23 | +suite "a saved session": | |
| 24 | + setup: clean() | |
| 25 | + test "round-trips, and says whether there was one": | |
| 26 | + check loadSession()[1] == false | |
| 27 | + check saveSession(SavedSession(brokerToken: "durable", handle: "alice.uk", | |
| 28 | + did: "did:plc:a", nick: "alice")) | |
| 29 | + let (s, had) = loadSession() | |
| 30 | + check had | |
| 31 | + check s.brokerToken == "durable" | |
| 32 | + check s.did == "did:plc:a" | |
| 33 | + test "with no broker token is not a session": | |
| 34 | + # The token is the whole point of saving one; the handle beside it is | |
| 35 | + # only there to say whose it is. | |
| 36 | + check saveSession(SavedSession(handle: "alice.uk")) | |
| 37 | + check loadSession()[1] == false | |
| 45 | 38 | test "is written so nobody else can read it": |
| 46 | - withTempConfig: | |
| 47 | - check saveSession(SavedSession(brokerToken: "tok")) | |
| 48 | - let perms = getFilePermissions(sessionFile()) | |
| 49 | - check fpGroupRead notin perms | |
| 50 | - check fpOthersRead notin perms | |
| 51 | - | |
| 52 | - test "clearing it leaves nothing behind": | |
| 53 | - withTempConfig: | |
| 54 | - check saveSession(SavedSession(brokerToken: "tok")) | |
| 55 | - clearSession() | |
| 56 | - check loadSession()[1] == false | |
| 57 | - | |
| 58 | - test "clearing one that is not there is not an error": | |
| 59 | - withTempConfig: | |
| 60 | - clearSession() | |
| 61 | - | |
| 62 | -suite "the rooms": | |
| 63 | - test "none when nothing was saved": | |
| 64 | - withTempConfig: | |
| 65 | - check loadRooms().len == 0 | |
| 66 | - | |
| 67 | - test "round-trip, with the markers": | |
| 68 | - # A phone that kept the names and lost the markers comes back to a | |
| 69 | - # hundred lines it has already read. | |
| 70 | - withTempConfig: | |
| 71 | - var rooms = initOrderedTable[string, Room]() | |
| 72 | - var a = initRoom("#one") | |
| 73 | - a.accessed = 5 | |
| 74 | - a.lastReadId = "m1" | |
| 75 | - a.lastReadAt = 1234 | |
| 76 | - rooms["#one"] = a | |
| 77 | - rooms["#two"] = initRoom("#two") | |
| 78 | - check saveRooms(rooms) | |
| 79 | - | |
| 80 | - let got = loadRooms() | |
| 81 | - check got.len == 2 | |
| 82 | - check got[0].name == "#one" | |
| 83 | - check got[0].accessed == 5 | |
| 84 | - check got[0].lastReadId == "m1" | |
| 85 | - check got[0].lastReadAt == 1234 | |
| 86 | - | |
| 87 | - test "order is kept — most recently used first is the file's own order": | |
| 88 | - withTempConfig: | |
| 89 | - var rooms = initOrderedTable[string, Room]() | |
| 90 | - for n in ["#c", "#a", "#b"]: rooms[n] = initRoom(n) | |
| 91 | - check saveRooms(rooms) | |
| 92 | - check loadRooms().len == 3 | |
| 93 | - check loadRooms()[0].name == "#c" | |
| 94 | - | |
| 95 | - test "a record with no name is dropped rather than defaulted": | |
| 96 | - withTempConfig: | |
| 97 | - createDir(configDir()) | |
| 98 | - writeFile(roomsFile(), """[{"name":"#ok"},{"accessed":3}]""") | |
| 99 | - let got = loadRooms() | |
| 100 | - check got.len == 1 | |
| 101 | - check got[0].name == "#ok" | |
| 102 | - | |
| 103 | - test "a file that will not parse is no rooms, not an error": | |
| 104 | - withTempConfig: | |
| 105 | - createDir(configDir()) | |
| 106 | - writeFile(roomsFile(), "not json at all") | |
| 107 | - check loadRooms().len == 0 | |
| 108 | - | |
| 109 | -suite "the preferences": | |
| 110 | - test "round-trip": | |
| 111 | - withTempConfig: | |
| 112 | - check savePrefs({"hideJoinPart": true, "showUsers": false}.toTable) | |
| 113 | - let got = loadPrefs() | |
| 114 | - check got["hideJoinPart"] == true | |
| 115 | - check got["showUsers"] == false | |
| 116 | - | |
| 117 | - test "absent is empty": | |
| 118 | - withTempConfig: | |
| 119 | - check loadPrefs().len == 0 | |
| 39 | + check saveSession(SavedSession(brokerToken: "durable")) | |
| 40 | + check getFilePermissions(sessionFile()) == {fpUserRead, fpUserWrite} | |
| 41 | + test "and restore brings it back as the Bluesky mode": | |
| 42 | + check saveSession(SavedSession(brokerToken: "durable", handle: "alice.uk", | |
| 43 | + nick: "alice")) | |
| 44 | + restore() | |
| 45 | + check app.brokerToken == "durable" | |
| 46 | + check app.authMode == amBluesky | |
| 47 | + check app.formHandle == "alice.uk" | |
| 48 | + check app.formNick == "alice" | |
| 49 | + | |
| 50 | +suite "the rooms file": | |
| 51 | + setup: clean() | |
| 52 | + | |
| 53 | + proc saved(): seq[SavedRoom] = | |
| 54 | + var rooms: OrderedTable[string, Room] | |
| 55 | + for (name, accessed, id, at) in [("#a", 100'i64, "m1", 900'i64), | |
| 56 | + ("#b", 300'i64, "m2", 800'i64), | |
| 57 | + ("carol", 200'i64, "", 0'i64)]: | |
| 58 | + var r = initRoom(name) | |
| 59 | + r.accessed = accessed | |
| 60 | + r.lastReadId = id | |
| 61 | + r.lastReadAt = at | |
| 62 | + r.messages = @[Message(id: "x", frm: "bob", text: "hi", at: 1000)] | |
| 63 | + rooms[name] = r | |
| 64 | + check saveRooms(rooms) | |
| 65 | + loadRooms() | |
| 66 | + | |
| 67 | + test "keeps the name and the marker, and not the messages": | |
| 68 | + # The list is the part worth keeping; the lines in it come from the | |
| 69 | + # server, and a client that saved them would be a second copy to go | |
| 70 | + # stale. | |
| 71 | + let rs = saved() | |
| 72 | + check rs.len == 3 | |
| 73 | + check rs[0].name == "#a" | |
| 74 | + check rs[0].lastReadId == "m1" | |
| 75 | + check rs[0].lastReadAt == 900 | |
| 76 | + | |
| 77 | + test "restore brings them back empty, unjoined, with their markers": | |
| 78 | + discard saved() | |
| 79 | + restore() | |
| 80 | + check app.rooms.len == 3 | |
| 81 | + check app.rooms["#a"].messages.len == 0 | |
| 82 | + check not app.rooms["#a"].joined | |
| 83 | + check app.rooms["#a"].lastReadId == "m1" | |
| 84 | + check app.rooms["#a"].accessed == 100 | |
| 85 | + check app.rooms["#b"].accessed == 300 | |
| 86 | + | |
| 87 | + test "and the most recently used is still top of the list": | |
| 88 | + discard saved() | |
| 89 | + restore() | |
| 90 | + check channelList(app.rooms, "")[0].name == "#b" | |
| 91 | + | |
| 92 | + test "a record with no marker is caught up, not unread from the start": | |
| 93 | + # An older frq's file, or one that lost it. The alternative announces a | |
| 94 | + # hundred lines the reader has already seen. | |
| 95 | + discard saved() | |
| 96 | + restore() | |
| 97 | + let before = nowMs() | |
| 98 | + check app.rooms["carol"].lastReadAt >= before - 5000 | |
| 99 | + check app.rooms["carol"].lastReadAt > 0 | |
| 100 | + | |
| 101 | + test "a room the reader has closed stays closed": | |
| 102 | + discard saved() | |
| 103 | + restore() | |
| 104 | + dispatch(%*{"id": "room.leave:#a"}) | |
| 105 | + app = initState() | |
| 106 | + restore() | |
| 107 | + check not app.rooms.hasKey("#a") | |
| 108 | + check app.rooms.hasKey("#b") | |
| 109 | + | |
| 110 | +suite "the prefs file": | |
| 111 | + setup: clean() | |
| 112 | + test "the three display toggles outlive the run": | |
| 113 | + restore() | |
| 114 | + let wasJoinPart = app.hideJoinPart | |
| 115 | + dispatch(%*{"id": "join-part.toggle"}) | |
| 116 | + dispatch(%*{"id": "users.toggle"}) | |
| 117 | + app = initState() | |
| 118 | + restore() | |
| 119 | + check app.hideJoinPart == not wasJoinPart | |
| 120 | + check app.showUsers | |
| 121 | + test "the overview is not one of them": | |
| 122 | + # It is a way of looking at the moment you are in rather than a | |
| 123 | + # preference, and reopening into it would answer a question nobody asked | |
| 124 | + # twice. | |
| 125 | + restore() | |
| 126 | + dispatch(%*{"id": "overview.toggle"}) | |
| 127 | + app = initState() | |
| 128 | + restore() | |
| 129 | + check not app.overview | |
| 130 | + test "and a prefs file that is not there is a first run, not an error": | |
| 131 | + check loadPrefs().len == 0 | |
| 132 | + restore() | |
| 133 | + check not app.hideJoinPart | |
| @@ -1,119 +1,133 @@ | |||
| 1 | -## What survives a restart. Every case runs against a real directory under a | 1 | +## What survives a restart, and what deliberately does not. |
| 2 | -## temporary XDG_CONFIG_HOME, because the thing being tested is files. | 2 | +## |
| 3 | - | 3 | +## Every test here points `XDG_CONFIG_HOME` at a directory of its own, set |
| 4 | -import std/[os, tables, unittest] | 4 | +## before `frq/store` is touched at all: the alternative is a suite that reads |
| 5 | -import frq/[store, model] | 5 | +## and writes the config of whoever runs it, which would both lie about the |
| 6 | - | 6 | +## result and cost them their room list. |
| 7 | -template withTempConfig(body: untyped) = | 7 | + |
| 8 | - let dir = getTempDir() / "frq-test-store-" & $getCurrentProcessId() | 8 | +import std/[json, os, sets, tables, times, unittest] |
| 9 | - removeDir(dir) | 9 | + |
| 10 | - createDir(dir) | 10 | +let sandbox = getTempDir() / "frq-tstore-" & $epochTime() |
| 11 | - putEnv("XDG_CONFIG_HOME", dir) | 11 | +putEnv("XDG_CONFIG_HOME", sandbox) |
| 12 | - defer: | 12 | + |
| 13 | - delEnv("XDG_CONFIG_HOME") | 13 | +import frq/[cells, clock, model, rooms, store, reducer] |
| 14 | - removeDir(dir) | 14 | + |
| 15 | - body | 15 | +proc clean() = |
| 16 | - | 16 | + removeDir(sandbox) |
| 17 | -suite "the session": | 17 | + app = initState() |
| 18 | - test "absent when nothing was saved": | 18 | + |
| 19 | - withTempConfig: | 19 | +suite "the config directory": |
| 20 | - check loadSession()[1] == false | 20 | + test "is under XDG_CONFIG_HOME where there is one": |
| 21 | - | 21 | + check configDir() == sandbox / "frq" |
| 22 | - test "round-trips": | 22 | + |
| 23 | - withTempConfig: | 23 | +suite "a saved session": |
| 24 | - check saveSession(SavedSession(brokerToken: "tok", handle: "alice", | 24 | + setup: clean() |
| 25 | - did: "did:plc:x", nick: "alice")) | 25 | + test "round-trips, and says whether there was one": |
| 26 | - let (s, ok) = loadSession() | 26 | + check loadSession()[1] == false |
| 27 | - check ok | 27 | + check saveSession(SavedSession(brokerToken: "durable", handle: "alice.uk", |
| 28 | - check s.brokerToken == "tok" | 28 | + did: "did:plc:a", nick: "alice")) |
| 29 | - check s.handle == "alice" | 29 | + let (s, had) = loadSession() |
| 30 | - check s.did == "did:plc:x" | 30 | + check had |
| 31 | - | 31 | + check s.brokerToken == "durable" |
| 32 | - test "one with no broker token is not a session": | 32 | + check s.did == "did:plc:a" |
| 33 | - # The token is the whole point of saving one. | 33 | + test "with no broker token is not a session": |
| 34 | - withTempConfig: | 34 | + # The token is the whole point of saving one; the handle beside it is |
| 35 | - check saveSession(SavedSession(handle: "alice")) | 35 | + # only there to say whose it is. |
| 36 | - check loadSession()[1] == false | 36 | + check saveSession(SavedSession(handle: "alice.uk")) |
| 37 | - | 37 | + check loadSession()[1] == false |
| 38 | - test "a file that will not parse is treated as absent": | ||
| 39 | - # A stale credential is not worth an error at startup. | ||
| 40 | - withTempConfig: | ||
| 41 | - createDir(configDir()) | ||
| 42 | - writeFile(sessionFile(), "{ this is not json") | ||
| 43 | - check loadSession()[1] == false | ||
| 44 | - | ||
| 45 | test "is written so nobody else can read it": | 38 | test "is written so nobody else can read it": |
| 46 | - withTempConfig: | 39 | + check saveSession(SavedSession(brokerToken: "durable")) |
| 47 | - check saveSession(SavedSession(brokerToken: "tok")) | 40 | + check getFilePermissions(sessionFile()) == {fpUserRead, fpUserWrite} |
| 48 | - let perms = getFilePermissions(sessionFile()) | 41 | + test "and restore brings it back as the Bluesky mode": |
| 49 | - check fpGroupRead notin perms | 42 | + check saveSession(SavedSession(brokerToken: "durable", handle: "alice.uk", |
| 50 | - check fpOthersRead notin perms | 43 | + nick: "alice")) |
| 51 | - | 44 | + restore() |
| 52 | - test "clearing it leaves nothing behind": | 45 | + check app.brokerToken == "durable" |
| 53 | - withTempConfig: | 46 | + check app.authMode == amBluesky |
| 54 | - check saveSession(SavedSession(brokerToken: "tok")) | 47 | + check app.formHandle == "alice.uk" |
| 55 | - clearSession() | 48 | + check app.formNick == "alice" |
| 56 | - check loadSession()[1] == false | 49 | + |
| 57 | - | 50 | +suite "the rooms file": |
| 58 | - test "clearing one that is not there is not an error": | 51 | + setup: clean() |
| 59 | - withTempConfig: | 52 | + |
| 60 | - clearSession() | 53 | + proc saved(): seq[SavedRoom] = |
| 61 | - | 54 | + var rooms: OrderedTable[string, Room] |
| 62 | -suite "the rooms": | 55 | + for (name, accessed, id, at) in [("#a", 100'i64, "m1", 900'i64), |
| 63 | - test "none when nothing was saved": | 56 | + ("#b", 300'i64, "m2", 800'i64), |
| 64 | - withTempConfig: | 57 | + ("carol", 200'i64, "", 0'i64)]: |
| 65 | - check loadRooms().len == 0 | 58 | + var r = initRoom(name) |
| 66 | - | 59 | + r.accessed = accessed |
| 67 | - test "round-trip, with the markers": | 60 | + r.lastReadId = id |
| 68 | - # A phone that kept the names and lost the markers comes back to a | 61 | + r.lastReadAt = at |
| 69 | - # hundred lines it has already read. | 62 | + r.messages = @[Message(id: "x", frm: "bob", text: "hi", at: 1000)] |
| 70 | - withTempConfig: | 63 | + rooms[name] = r |
| 71 | - var rooms = initOrderedTable[string, Room]() | 64 | + check saveRooms(rooms) |
| 72 | - var a = initRoom("#one") | 65 | + loadRooms() |
| 73 | - a.accessed = 5 | 66 | + |
| 74 | - a.lastReadId = "m1" | 67 | + test "keeps the name and the marker, and not the messages": |
| 75 | - a.lastReadAt = 1234 | 68 | + # The list is the part worth keeping; the lines in it come from the |
| 76 | - rooms["#one"] = a | 69 | + # server, and a client that saved them would be a second copy to go |
| 77 | - rooms["#two"] = initRoom("#two") | 70 | + # stale. |
| 78 | - check saveRooms(rooms) | 71 | + let rs = saved() |
| 79 | - | 72 | + check rs.len == 3 |
| 80 | - let got = loadRooms() | 73 | + check rs[0].name == "#a" |
| 81 | - check got.len == 2 | 74 | + check rs[0].lastReadId == "m1" |
| 82 | - check got[0].name == "#one" | 75 | + check rs[0].lastReadAt == 900 |
| 83 | - check got[0].accessed == 5 | 76 | + |
| 84 | - check got[0].lastReadId == "m1" | 77 | + test "restore brings them back empty, unjoined, with their markers": |
| 85 | - check got[0].lastReadAt == 1234 | 78 | + discard saved() |
| 86 | - | 79 | + restore() |
| 87 | - test "order is kept — most recently used first is the file's own order": | 80 | + check app.rooms.len == 3 |
| 88 | - withTempConfig: | 81 | + check app.rooms["#a"].messages.len == 0 |
| 89 | - var rooms = initOrderedTable[string, Room]() | 82 | + check not app.rooms["#a"].joined |
| 90 | - for n in ["#c", "#a", "#b"]: rooms[n] = initRoom(n) | 83 | + check app.rooms["#a"].lastReadId == "m1" |
| 91 | - check saveRooms(rooms) | 84 | + check app.rooms["#a"].accessed == 100 |
| 92 | - check loadRooms().len == 3 | 85 | + check app.rooms["#b"].accessed == 300 |
| 93 | - check loadRooms()[0].name == "#c" | 86 | + |
| 94 | - | 87 | + test "and the most recently used is still top of the list": |
| 95 | - test "a record with no name is dropped rather than defaulted": | 88 | + discard saved() |
| 96 | - withTempConfig: | 89 | + restore() |
| 97 | - createDir(configDir()) | 90 | + check channelList(app.rooms, "")[0].name == "#b" |
| 98 | - writeFile(roomsFile(), """[{"name":"#ok"},{"accessed":3}]""") | 91 | + |
| 99 | - let got = loadRooms() | 92 | + test "a record with no marker is caught up, not unread from the start": |
| 100 | - check got.len == 1 | 93 | + # An older frq's file, or one that lost it. The alternative announces a |
| 101 | - check got[0].name == "#ok" | 94 | + # hundred lines the reader has already seen. |
| 102 | - | 95 | + discard saved() |
| 103 | - test "a file that will not parse is no rooms, not an error": | 96 | + restore() |
| 104 | - withTempConfig: | 97 | + let before = nowMs() |
| 105 | - createDir(configDir()) | 98 | + check app.rooms["carol"].lastReadAt >= before - 5000 |
| 106 | - writeFile(roomsFile(), "not json at all") | 99 | + check app.rooms["carol"].lastReadAt > 0 |
| 107 | - check loadRooms().len == 0 | 100 | + |
| 108 | - | 101 | + test "a room the reader has closed stays closed": |
| 109 | -suite "the preferences": | 102 | + discard saved() |
| 110 | - test "round-trip": | 103 | + restore() |
| 111 | - withTempConfig: | 104 | + dispatch(%*{"id": "room.leave:#a"}) |
| 112 | - check savePrefs({"hideJoinPart": true, "showUsers": false}.toTable) | 105 | + app = initState() |
| 113 | - let got = loadPrefs() | 106 | + restore() |
| 114 | - check got["hideJoinPart"] == true | 107 | + check not app.rooms.hasKey("#a") |
| 115 | - check got["showUsers"] == false | 108 | + check app.rooms.hasKey("#b") |
| 116 | - | 109 | + |
| 117 | - test "absent is empty": | 110 | +suite "the prefs file": |
| 118 | - withTempConfig: | 111 | + setup: clean() |
| 119 | - check loadPrefs().len == 0 | 112 | + test "the three display toggles outlive the run": |
| 113 | + restore() | ||
| 114 | + let wasJoinPart = app.hideJoinPart | ||
| 115 | + dispatch(%*{"id": "join-part.toggle"}) | ||
| 116 | + dispatch(%*{"id": "users.toggle"}) | ||
| 117 | + app = initState() | ||
| 118 | + restore() | ||
| 119 | + check app.hideJoinPart == not wasJoinPart | ||
| 120 | + check app.showUsers | ||
| 121 | + test "the overview is not one of them": | ||
| 122 | + # It is a way of looking at the moment you are in rather than a | ||
| 123 | + # preference, and reopening into it would answer a question nobody asked | ||
| 124 | + # twice. | ||
| 125 | + restore() | ||
| 126 | + dispatch(%*{"id": "overview.toggle"}) | ||
| 127 | + app = initState() | ||
| 128 | + restore() | ||
| 129 | + check not app.overview | ||
| 130 | + test "and a prefs file that is not there is a first run, not an error": | ||
| 131 | + check loadPrefs().len == 0 | ||
| 132 | + restore() | ||
| 133 | + check not app.hideJoinPart | ||