nandi/frqpublic Fork 0
a4be36c
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-19T09:14:44-07:00 Browse files
a4be36c parent: bdd2c3c
modified nim/src/frq/reducer.nim +133 -10
@@ -35,6 +35,10 @@ proc setError(msg: string) =
3535 app.error = msg
3636 app.hasError = true
3737
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+
3842 proc send(line: string) =
3943 trace("out", line)
4044 tr.send(line)
@@ -49,16 +53,100 @@ proc openRoom(name: string) =
4953 app.atPresent = true
5054 # Opening a room is reading it: the marker moves to the newest line here.
5155 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)
52135
53136 proc restore*() =
54137 ## What a previous run left on disk, back in the state.
55138 ##
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()
60144 let (saved, had) = loadSession()
61145 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.
62150 app.brokerToken = saved.brokerToken
63151 app.authMode = amBluesky
64152 if saved.handle.len > 0: app.formHandle = saved.handle
@@ -232,6 +320,7 @@ proc dispatch*(event: JsonNode) =
232320 of "connect": connectNow()
233321
234322 of "cancel", "disconnect":
323+ rememberRooms(force = true)
235324 # A browser wait is part of connecting, so Cancel ends it too — otherwise
236325 # a tab finished ten minutes later would sign in behind the reader.
237326 oa.cancel()
@@ -269,6 +358,9 @@ proc dispatch*(event: JsonNode) =
269358 if app.rooms.hasKey(arg):
270359 if not dm(arg): send("PART " & arg)
271360 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)
272364 if app.current == arg:
273365 app.current = ""
274366 app.screen = scChats
@@ -291,10 +383,23 @@ proc dispatch*(event: JsonNode) =
291383 of "search.clear": app.search = ""
292384
293385 # ---------------------------------------------------------- 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()
298403 of "jump.present":
299404 app.atPresent = true
300405 app.jumpTick += 1
@@ -520,7 +625,22 @@ proc drain*() =
520625 app.connecting = false
521626 app.status = "Connected as " & app.formNick
522627 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")
524644
525645 of "PRIVMSG":
526646 if p.params.len >= 2:
@@ -628,4 +748,7 @@ proc drain*() =
628748 else:
629749 trace("skip", p.command & " " & $p.params)
630750
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 = msg35 app.error = msg
36 app.hasError = true36 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 = true53 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].markRead55 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 use140+ ## 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 the141+ ## 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: return145 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.brokerToken150 app.brokerToken = saved.brokerToken
63 app.authMode = amBluesky151 app.authMode = amBluesky
64 if saved.handle.len > 0: app.formHandle = saved.handle152 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 — otherwise324 # 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 = scChats366 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 chrome385 # ---------------------------------------------------------- chat chrome
294- of "chat-list.toggle": app.hideChatList = not app.hideChatList386+ # The three that outlive the run are written as they are pressed. There is
295- of "users.toggle": app.showUsers = not app.showUsers387+ # no Save on this screen and no moment that is obviously the last one — the
296- of "overview.toggle": app.overview = not app.overview388+ # window closes when it closes.
297- of "join-part.toggle": app.hideJoinPart = not app.hideJoinPart389+ 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 = true404 app.atPresent = true
300 app.jumpTick += 1405 app.jumpTick += 1
@@ -520,7 +625,22 @@ proc drain*() =
520 app.connecting = false625 app.connecting = false
521 app.status = "Connected as " & app.formNick626 app.status = "Connected as " & app.formNick
522 app.screen = scChats627 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
4538 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 a1+## 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- body15+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] == false20+ 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 ok27+ 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] == false36+ 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 perms42+ check saveSession(SavedSession(brokerToken: "durable", handle: "alice.uk",
50- check fpOthersRead notin perms43+ 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] == false49+
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 == 058+ 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 a61+ 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 = 566+
74- a.lastReadId = "m1"67+ test "keeps the name and the marker, and not the messages":
75- a.lastReadAt = 123468+ # The list is the part worth keeping; the lines in it come from the
76- rooms["#one"] = a69+ # 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 == 274+ check rs[0].lastReadId == "m1"
82- check got[0].name == "#one"75+ check rs[0].lastReadAt == 900
83- check got[0].accessed == 576+
84- check got[0].lastReadId == "m1"77+ test "restore brings them back empty, unjoined, with their markers":
85- check got[0].lastReadAt == 123478+ 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 == 385+ 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 == 193+ # 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 == 0100+
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"] == true107+ check not app.rooms.hasKey("#a")
115- check got["showUsers"] == false108+ 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 == 0112+ 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