nandi/frqpublic Fork 0
bd10e816d10d09c3dbe1256c1b7c661d8e190ee6
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.

tscreens.nim · 353 lines · 13.7 KBNim Blame HistoryRaw
Four screens, in Nim c69a09b nandi yesterday1## The screens, as pure functions of the state.
2##
3## These are the tests the ClojureDart screens never had and could not easily
4## have: a screen there is hiccup over cells a host installs, so exercising one
5## means standing up a host. Here it is a function from a record to a tree.
6
7import std/[json, sequtils, strutils, tables, unicode, unittest]
8import frq/[ui, cells, model]
9import frq/screens/connect as cs
10import frq/screens/settings as ss
A window with two edges, and something against each of them 0e7cd32 nandi 12h ago11import frq/screens/chat as cht
12import frq/[rooms]
Four screens, in Nim c69a09b nandi yesterday13
14proc find*(node: Node, tag: string): seq[Node] =
15 if node.isNil: return
16 if node.tag == tag: result.add node
17 for c in node.children: result.add c.find(tag)
18
19proc labels(node: Node, tag: string): seq[string] =
20 node.find(tag).mapIt(it.props{"label"}.getStr())
21
22proc keys(node: Node, tag: string): seq[string] =
23 node.find(tag).mapIt(it.props{"key"}.getStr())
24
25suite "the connect screen":
26 setup:
27 var s = initState()
28
29 test "is a page with the title and the transport note":
30 let t = cs.connectScreen(s)
31 check t.tag == "page"
32 check "frq" in t.labels("title")
33 check cs.transportNote in t.labels("dim-label")
34
35 test "is pure — twice with no change is the same tree":
36 check $cs.connectScreen(s).toJson == $cs.connectScreen(s).toJson
37
38 test "guest is the default, and shows a nick field":
39 let t = cs.connectScreen(s)
40 check "Connect as guest" in t.labels("title-2")
41 check "nick" in t.keys("entry")
42 check "handle" notin t.keys("entry")
43
44 test "bluesky shows a handle and its own copy":
45 s.authMode = amBluesky
46 let t = cs.connectScreen(s)
47 check "Sign in with Bluesky" in t.labels("title-2")
48 check "handle" in t.keys("entry")
49 check "nick" notin t.keys("entry")
50
Sign in with Bluesky, through the broker bdd2c3c nandi yesterday51 test "and says what Connect will actually do":
52 # This used to assert the opposite — that the screen admitted the broker
53 # flow was not wired up — which was the honest copy while it was not.
54 # A window opening on its own is alarming without a line saying it will.
SASL, and a screen that stops lying about it e50cbc7 nandi yesterday55 s.authMode = amBluesky
Sign in with Bluesky, through the broker bdd2c3c nandi yesterday56 let dim = cs.connectScreen(s).labels("dim-label")
57 check dim.anyIt("opens your browser" in it)
58 check dim.anyIt("never reaches this app" in it)
SASL, and a screen that stops lying about it e50cbc7 nandi yesterday59
Four screens, in Nim c69a09b nandi yesterday60 test "app-password asks for both, and says where to make one":
61 s.authMode = amAppPassword
62 let t = cs.connectScreen(s)
63 check "handle" in t.keys("entry")
64 check "app-password" in t.keys("entry")
65 check t.labels("dim-label").anyIt("App Passwords" in it)
66
67 test "a remembered session offers to be forgotten, and only then":
68 s.authMode = amBluesky
69 check "Forget saved session" notin cs.connectScreen(s).labels("button")
70 s.brokerToken = "tok"
71 check "Forget saved session" in cs.connectScreen(s).labels("button")
72
73 test "the login URL is shown while the browser is open":
74 s.authMode = amBluesky
75 s.loginUrl = "https://auth.freeq.at/x"
76 let t = cs.connectScreen(s)
77 check "https://auth.freeq.at/x" in t.labels("label")
78
79 test "the error note keeps its place whether or not there is an error":
80 # The bug the stable wrapper exists for: a renderer matching children by
81 # position would patch the header into a card when the error appeared.
82 let before = cs.connectScreen(s).children.mapIt(it.tag)
83 s.hasError = true
84 s.error = "nope"
85 check cs.connectScreen(s).children.mapIt(it.tag) == before
86 check "Dismiss" in cs.connectScreen(s).labels("button")
87
88 test "the remembered and login-url wrappers hold their place too":
89 s.authMode = amBluesky
90 let bare = cs.connectScreen(s)
91 s.brokerToken = "tok"
92 s.loginUrl = "https://x"
93 check cs.connectScreen(s).keys("vbox").filterIt(it.len > 0) ==
94 bare.keys("vbox").filterIt(it.len > 0)
95
96 test "connecting swaps Connect for a spinner and a way out":
97 s.connecting = true
98 let t = cs.connectScreen(s)
99 check t.find("spinner").len == 1
100 check "Connect" notin t.labels("button")
101 check "Cancel" in t.labels("button")
102
103suite "discover":
104 setup:
105 var s = initState()
106
107 test "lists the popular channels with their blurbs":
108 let t = ss.discoverScreen(s)
109 check t.labels("title-2") == popularChannels.mapIt(it[0])
110 check "#general" in t.keys("card")
111
112 test "offers Join for a room we are not in and Open for one we are":
113 check ss.discoverScreen(s).labels("button").countIt(it == "Join") ==
114 popularChannels.len
115 var r = initRoom("#test"); r.joined = true
116 s.rooms["#test"] = r
117 let t = ss.discoverScreen(s)
118 check "Open" in t.labels("button")
119 check t.labels("button").countIt(it == "Join") == popularChannels.len - 1
120
121 test "the tab bar is under it, with Discover selected":
122 let t = ss.discoverScreen(s)
123 s.screen = scDiscover
124 let sel = ss.discoverScreen(s).find("button")
125 .filterIt(it.props{"kind"}.getStr() == "primary")
126 .mapIt(it.props{"label"}.getStr())
127 check "Discover" in sel
128 check t.find("scroll").len == 1
129
130suite "settings":
131 setup:
132 var s = initState()
133
134 test "a guest is told so":
135 check "Guest — not signed in." in
136 ss.settingsScreen(s, connected = false, desktop = true).labels("dim-label")
137
138 test "a signed-in handle is shown, and can be forgotten":
139 s.formHandle = "alice.bsky.social"
140 s.brokerToken = "tok"
141 let t = ss.settingsScreen(s, connected = true, desktop = true)
142 check "alice.bsky.social" in t.labels("label")
143 check "Forget Bluesky session" in t.labels("button")
144
145 test "Disconnect when connected, Back to connect when not":
146 check "Disconnect" in
147 ss.settingsScreen(s, connected = true, desktop = true).labels("button")
148 check "Back to connect" in
149 ss.settingsScreen(s, connected = false, desktop = true).labels("button")
150
151 test "Quit only where there is a window to quit":
152 check "Quit" in
153 ss.settingsScreen(s, connected = false, desktop = true).labels("button")
154 check "Quit" notin
155 ss.settingsScreen(s, connected = false, desktop = false).labels("button")
156
157 test "the join/part switch reflects the cell":
158 let off = ss.settingsScreen(s, false, true).find("checkbutton")[0]
159 check not off.props{"active"}.getBool()
160 s.hideJoinPart = true
161 let on = ss.settingsScreen(s, false, true).find("checkbutton")[0]
162 check on.props{"active"}.getBool()
163
164 test "the status line is live only when connected":
165 check ss.settingsScreen(s, true, true).find("status")[0]
166 .props{"live"}.getBool()
167 check not ss.settingsScreen(s, false, true).find("status")[0]
168 .props{"live"}.getBool()
169
170import frq/screens/chats as ch
171
172suite "previewLine":
173 test "collapses whitespace so a card reads as one line":
174 check ch.previewLine("a\n b\tc") == "a b c"
175 test "truncates a pasted script rather than growing the card":
176 let long = "x".repeat(200)
177 check ch.previewLine(long).runeLen == 60
178 check ch.previewLine(long).endsWith("")
179
180 test "truncates by character, not by byte":
181 # A byte slice at 59 lands inside a multi-byte character and produces
182 # mojibake where an ellipsis was wanted. The first draft did exactly that.
183 let emoji = "😀".repeat(100)
184 let got = ch.previewLine(emoji)
185 check got.runeLen == 60
186 check got.validateUtf8 == -1
187 test "leaves a short line alone":
188 check ch.previewLine("hello there") == "hello there"
189
190suite "the chats screen":
191 setup:
192 var s = initState()
193 var r = initRoom("#test")
194 r.joined = true
195 r.messages = @[Message(frm: "alice", text: "hello")]
196 s.rooms["#test"] = r
197
198 test "an empty list says so instead of showing nothing":
199 var empty = initState()
200 check "No conversations yet — join a channel." in
201 ch.chatsScreen(empty, false).labels("dim-label")
202
203 test "a room is a card with its name and last line":
204 let t = ch.chatsScreen(s, true)
205 check "#test" in t.labels("title-2")
206 check "alice: hello" in t.labels("dim-label")
207
208 test "the title says who we are when connected":
209 check "Logged in as frq-guest" in ch.chatsScreen(s, true).labels("title")
210 check "Chats" in ch.chatsScreen(s, false).labels("title")
211
212 test "an unread count shows, with a mention marked differently":
213 var u = s.rooms["#test"]
214 u.unread = 3
215 s.rooms["#test"] = u
216 check "● 3" in ch.chatsScreen(s, true).labels("label")
217 u.mention = true
218 s.rooms["#test"] = u
219 check "◆ @ 3" in ch.chatsScreen(s, true).labels("label")
220
221 test "a channel we are not in is badged, a DM never is":
222 var away = s.rooms["#test"]
223 away.joined = false
224 s.rooms["#test"] = away
225 check "not joined" in ch.chatsScreen(s, true).labels("status")
226 var dmr = initRoom("alice")
227 s.rooms["alice"] = dmr
228 # Still only the one badge: a DM has nothing to join.
229 check ch.chatsScreen(s, true).labels("status").countIt(it == "not joined") == 1
230
231 test "the button says Message for a nick and Join for a channel":
232 s.joinInput = "#room"
233 check "Join" in ch.chatsScreen(s, true).labels("button")
234 s.joinInput = "@alice"
235 check "Message" in ch.chatsScreen(s, true).labels("button")
236
237 test "the search box offers a clear only when there is something to clear":
238 check "" notin ch.chatsScreen(s, true).labels("button")
239 s.search = "te"
240 check "" in ch.chatsScreen(s, true).labels("button")
241
242 test "search filters the list":
243 s.rooms["#other"] = initRoom("#other")
244 s.search = "oth"
245 let names = ch.chatsScreen(s, true).labels("title-2")
246 check names == @["#other"]
A window with two edges, and something against each of them 0e7cd32 nandi 12h ago247
248suite "the chat screen's panes":
249 setup:
250 var s = initState()
251 s.formNick = "me"
252 s.windowWidth = wideWidth # both panes on screen
253 s.rooms.ensureRoom("#test")
254 var r = s.rooms["#test"]
255 r.joined = true
256 r.users = {"me": "", "alice": "@"}.toTable
257 r.messages = @[Message(id: "1", frm: "alice", text: "hi",
258 at: 1_700_000_000_000'i64)]
259 s.rooms["#test"] = r
260 s.current = "#test"
The way to Discover and Settings, from a conversation e20f5ab nandi 12h ago261 # What `room.open` does, and what the tab bar reads to light Chats.
262 s.screen = scChat
A window with two edges, and something against each of them 0e7cd32 nandi 12h ago263
264 test "the backlog and the rooms sit side by side when the list is up":
265 # The toggle had a button and rendered nothing at all: `hideChatList` was
266 # read for the button's own highlight and by nobody else.
267 s.hideChatList = false
268 let t = cht.chatScreen(s, true)
269 check "chat-list-pane" in t.keys("vbox")
270 check t.find("vbox").anyIt(it.props{"key"}.getStr() == "chat-list-pane" and
271 it.children.len > 0)
272 check "messages" in t.keys("vbox")
273
274 test "and the strip goes away when it is folded":
275 s.hideChatList = true
276 let t = cht.chatScreen(s, true)
277 check t.find("vbox").anyIt(it.props{"key"}.getStr() == "chat-list-pane" and
278 it.children.len == 0)
279
280 test "the people strip is beside the backlog, not instead of it":
281 # It used to take the whole pane on a narrow window, so asking who was in
282 # a room meant losing the room while you looked.
283 s.windowWidth = wideWidth - 1 # one pane at a time
284 s.showUsers = true
285 let t = cht.chatScreen(s, true)
286 check t.find("vbox").anyIt(it.props{"key"}.getStr() == "people-pane" and
287 it.children.len > 0)
288 check t.find("scroll").anyIt(
289 it.props{"scrollKey"}.getStr() == "messages-#test")
290
The way to Discover and Settings, from a conversation e20f5ab nandi 12h ago291 test "the way to Discover and Settings is on the screen":
292 # It was not: the chat screen never carried the tab bar, and on a wide
293 # window there is no back button either — the room list is a strip. So
294 # from a conversation there was no way to either of them at all.
295 let t = cht.chatScreen(s, true)
296 check "Discover" in t.labels("button")
297 check "Settings" in t.labels("button")
298
299 test "and Chats is lit, because a conversation is what it leads to":
300 let t = cht.chatScreen(s, true)
301 let chats = t.find("button").filterIt(
302 it.props{"label"}.getStr() == "Chats")
303 check chats.len == 1
304 check chats[0].props{"kind"}.getStr() == "primary"
305
306 test "but not on a narrow window, which has no room for them":
307 # Three tabs go to two lines at 300 points, and this screen has no 120
308 # points to spare — `← Chats` is the way back there instead.
309 s.windowWidth = wideWidth - 1
310 let t = cht.chatScreen(s, true)
311 check "Discover" notin t.labels("button")
312 check "← Chats" in t.labels("button")
313
A window with two edges, and something against each of them 0e7cd32 nandi 12h ago314 test "a room in the strip opens it, and the current one is lit":
315 s.hideChatList = false
316 let t = cht.chatScreen(s, true)
317 let side = t.find("button").filterIt(
318 it.props{"key"}.getStr().startsWith("side-"))
319 check side.len == 1
320 check side[0].props{"onClick"}.getStr() == "room.open:#test"
321 check side[0].props{"kind"}.getStr() == "primary"
322
323suite "the sender's row":
324 setup:
325 var s = initState()
326 s.formNick = "me"
327 s.rooms.ensureRoom("#test")
328 var r = s.rooms["#test"]
329 r.messages = @[Message(id: "1", frm: "alice", text: "hi",
330 at: 1_700_000_000_000'i64)]
331 s.rooms["#test"] = r
332 s.current = "#test"
333
334 test "on a wide window the chips are carried to the right edge":
335 # `align: end` on the chips never did anything: the row was a Wrap, which
336 # packs from the left and has no slack to align with. A stretch in a row
337 # that does not wrap is what moves them.
338 s.windowWidth = wideWidth
339 let t = cht.chatScreen(s, true)
340 check t.find("spacer").anyIt(it.props{"expand"}.getBool())
341 check t.find("hbox").anyIt(not it.props{"wrap"}.getBool() and
342 it.children.anyIt(it.tag == "avatar"))
343
344 test "on a narrow one it wraps instead, and nothing is pushed off":
345 # With the name shrunk to nothing the face, the time and three chips
346 # still ask for more than a phone has, so there the row wraps as before.
347 s.windowWidth = wideWidth - 1 # one pane at a time
348 let t = cht.chatScreen(s, true)
349 check not t.find("spacer").anyIt(it.props{"expand"}.getBool())
350 let senderRows = t.find("hbox").filterIt(
351 it.children.anyIt(it.tag == "avatar"))
352 check senderRows.len == 1
353 check senderRows[0].props{"wrap"}.getBool()