| Four screens, in Nim c69a09b nandi yesterday | 1 | ## 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 | |
| 7 | import std/[json, sequtils, strutils, tables, unicode, unittest] |
| 8 | import frq/[ui, cells, model] |
| 9 | import frq/screens/connect as cs |
| 10 | import frq/screens/settings as ss |
| A window with two edges, and something against each of them 0e7cd32 nandi 9h ago | 11 | import frq/screens/chat as cht |
| 12 | import frq/[rooms] |
| Four screens, in Nim c69a09b nandi yesterday | 13 | |
| 14 | proc 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 | |
| 19 | proc labels(node: Node, tag: string): seq[string] = |
| 20 | node.find(tag).mapIt(it.props{"label"}.getStr()) |
| 21 | |
| 22 | proc keys(node: Node, tag: string): seq[string] = |
| 23 | node.find(tag).mapIt(it.props{"key"}.getStr()) |
| 24 | |
| 25 | suite "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 23h ago | 51 | 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 yesterday | 55 | s.authMode = amBluesky |
| Sign in with Bluesky, through the broker bdd2c3c nandi 23h ago | 56 | 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 yesterday | 59 | |
| Four screens, in Nim c69a09b nandi yesterday | 60 | 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 | |
| 103 | suite "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 | |
| 130 | suite "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 | |
| 170 | import frq/screens/chats as ch |
| 171 | |
| 172 | suite "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 | |
| 190 | suite "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 9h ago | 247 | |
| 248 | suite "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 8h ago | 261 | # 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 9h ago | 263 | |
| 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 8h ago | 291 | 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 9h ago | 314 | 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 | |
| 323 | suite "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() |