| Four screens, in Nim c69a09b nandi 18h ago | 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 |
| 11 | |
| 12 | proc find*(node: Node, tag: string): seq[Node] = |
| 13 | if node.isNil: return |
| 14 | if node.tag == tag: result.add node |
| 15 | for c in node.children: result.add c.find(tag) |
| 16 | |
| 17 | proc labels(node: Node, tag: string): seq[string] = |
| 18 | node.find(tag).mapIt(it.props{"label"}.getStr()) |
| 19 | |
| 20 | proc keys(node: Node, tag: string): seq[string] = |
| 21 | node.find(tag).mapIt(it.props{"key"}.getStr()) |
| 22 | |
| 23 | suite "the connect screen": |
| 24 | setup: |
| 25 | var s = initState() |
| 26 | |
| 27 | test "is a page with the title and the transport note": |
| 28 | let t = cs.connectScreen(s) |
| 29 | check t.tag == "page" |
| 30 | check "frq" in t.labels("title") |
| 31 | check cs.transportNote in t.labels("dim-label") |
| 32 | |
| 33 | test "is pure — twice with no change is the same tree": |
| 34 | check $cs.connectScreen(s).toJson == $cs.connectScreen(s).toJson |
| 35 | |
| 36 | test "guest is the default, and shows a nick field": |
| 37 | let t = cs.connectScreen(s) |
| 38 | check "Connect as guest" in t.labels("title-2") |
| 39 | check "nick" in t.keys("entry") |
| 40 | check "handle" notin t.keys("entry") |
| 41 | |
| 42 | test "bluesky shows a handle and its own copy": |
| 43 | s.authMode = amBluesky |
| 44 | let t = cs.connectScreen(s) |
| 45 | check "Sign in with Bluesky" in t.labels("title-2") |
| 46 | check "handle" in t.keys("entry") |
| 47 | check "nick" notin t.keys("entry") |
| 48 | |
| SASL, and a screen that stops lying about it e50cbc7 nandi 17h ago | 49 | test "and admits the broker flow is not wired up": |
| 50 | # A screen that describes the finished thing is a screen that lies. |
| 51 | s.authMode = amBluesky |
| 52 | check cs.connectScreen(s).labels("dim-label").anyIt("Not wired up yet" in it) |
| 53 | |
| Four screens, in Nim c69a09b nandi 18h ago | 54 | test "app-password asks for both, and says where to make one": |
| 55 | s.authMode = amAppPassword |
| 56 | let t = cs.connectScreen(s) |
| 57 | check "handle" in t.keys("entry") |
| 58 | check "app-password" in t.keys("entry") |
| 59 | check t.labels("dim-label").anyIt("App Passwords" in it) |
| 60 | |
| 61 | test "a remembered session offers to be forgotten, and only then": |
| 62 | s.authMode = amBluesky |
| 63 | check "Forget saved session" notin cs.connectScreen(s).labels("button") |
| 64 | s.brokerToken = "tok" |
| 65 | check "Forget saved session" in cs.connectScreen(s).labels("button") |
| 66 | |
| 67 | test "the login URL is shown while the browser is open": |
| 68 | s.authMode = amBluesky |
| 69 | s.loginUrl = "https://auth.freeq.at/x" |
| 70 | let t = cs.connectScreen(s) |
| 71 | check "https://auth.freeq.at/x" in t.labels("label") |
| 72 | |
| 73 | test "the error note keeps its place whether or not there is an error": |
| 74 | # The bug the stable wrapper exists for: a renderer matching children by |
| 75 | # position would patch the header into a card when the error appeared. |
| 76 | let before = cs.connectScreen(s).children.mapIt(it.tag) |
| 77 | s.hasError = true |
| 78 | s.error = "nope" |
| 79 | check cs.connectScreen(s).children.mapIt(it.tag) == before |
| 80 | check "Dismiss" in cs.connectScreen(s).labels("button") |
| 81 | |
| 82 | test "the remembered and login-url wrappers hold their place too": |
| 83 | s.authMode = amBluesky |
| 84 | let bare = cs.connectScreen(s) |
| 85 | s.brokerToken = "tok" |
| 86 | s.loginUrl = "https://x" |
| 87 | check cs.connectScreen(s).keys("vbox").filterIt(it.len > 0) == |
| 88 | bare.keys("vbox").filterIt(it.len > 0) |
| 89 | |
| 90 | test "connecting swaps Connect for a spinner and a way out": |
| 91 | s.connecting = true |
| 92 | let t = cs.connectScreen(s) |
| 93 | check t.find("spinner").len == 1 |
| 94 | check "Connect" notin t.labels("button") |
| 95 | check "Cancel" in t.labels("button") |
| 96 | |
| 97 | suite "discover": |
| 98 | setup: |
| 99 | var s = initState() |
| 100 | |
| 101 | test "lists the popular channels with their blurbs": |
| 102 | let t = ss.discoverScreen(s) |
| 103 | check t.labels("title-2") == popularChannels.mapIt(it[0]) |
| 104 | check "#general" in t.keys("card") |
| 105 | |
| 106 | test "offers Join for a room we are not in and Open for one we are": |
| 107 | check ss.discoverScreen(s).labels("button").countIt(it == "Join") == |
| 108 | popularChannels.len |
| 109 | var r = initRoom("#test"); r.joined = true |
| 110 | s.rooms["#test"] = r |
| 111 | let t = ss.discoverScreen(s) |
| 112 | check "Open" in t.labels("button") |
| 113 | check t.labels("button").countIt(it == "Join") == popularChannels.len - 1 |
| 114 | |
| 115 | test "the tab bar is under it, with Discover selected": |
| 116 | let t = ss.discoverScreen(s) |
| 117 | s.screen = scDiscover |
| 118 | let sel = ss.discoverScreen(s).find("button") |
| 119 | .filterIt(it.props{"kind"}.getStr() == "primary") |
| 120 | .mapIt(it.props{"label"}.getStr()) |
| 121 | check "Discover" in sel |
| 122 | check t.find("scroll").len == 1 |
| 123 | |
| 124 | suite "settings": |
| 125 | setup: |
| 126 | var s = initState() |
| 127 | |
| 128 | test "a guest is told so": |
| 129 | check "Guest — not signed in." in |
| 130 | ss.settingsScreen(s, connected = false, desktop = true).labels("dim-label") |
| 131 | |
| 132 | test "a signed-in handle is shown, and can be forgotten": |
| 133 | s.formHandle = "alice.bsky.social" |
| 134 | s.brokerToken = "tok" |
| 135 | let t = ss.settingsScreen(s, connected = true, desktop = true) |
| 136 | check "alice.bsky.social" in t.labels("label") |
| 137 | check "Forget Bluesky session" in t.labels("button") |
| 138 | |
| 139 | test "Disconnect when connected, Back to connect when not": |
| 140 | check "Disconnect" in |
| 141 | ss.settingsScreen(s, connected = true, desktop = true).labels("button") |
| 142 | check "Back to connect" in |
| 143 | ss.settingsScreen(s, connected = false, desktop = true).labels("button") |
| 144 | |
| 145 | test "Quit only where there is a window to quit": |
| 146 | check "Quit" in |
| 147 | ss.settingsScreen(s, connected = false, desktop = true).labels("button") |
| 148 | check "Quit" notin |
| 149 | ss.settingsScreen(s, connected = false, desktop = false).labels("button") |
| 150 | |
| 151 | test "the join/part switch reflects the cell": |
| 152 | let off = ss.settingsScreen(s, false, true).find("checkbutton")[0] |
| 153 | check not off.props{"active"}.getBool() |
| 154 | s.hideJoinPart = true |
| 155 | let on = ss.settingsScreen(s, false, true).find("checkbutton")[0] |
| 156 | check on.props{"active"}.getBool() |
| 157 | |
| 158 | test "the status line is live only when connected": |
| 159 | check ss.settingsScreen(s, true, true).find("status")[0] |
| 160 | .props{"live"}.getBool() |
| 161 | check not ss.settingsScreen(s, false, true).find("status")[0] |
| 162 | .props{"live"}.getBool() |
| 163 | |
| 164 | import frq/screens/chats as ch |
| 165 | |
| 166 | suite "previewLine": |
| 167 | test "collapses whitespace so a card reads as one line": |
| 168 | check ch.previewLine("a\n b\tc") == "a b c" |
| 169 | test "truncates a pasted script rather than growing the card": |
| 170 | let long = "x".repeat(200) |
| 171 | check ch.previewLine(long).runeLen == 60 |
| 172 | check ch.previewLine(long).endsWith("…") |
| 173 | |
| 174 | test "truncates by character, not by byte": |
| 175 | # A byte slice at 59 lands inside a multi-byte character and produces |
| 176 | # mojibake where an ellipsis was wanted. The first draft did exactly that. |
| 177 | let emoji = "😀".repeat(100) |
| 178 | let got = ch.previewLine(emoji) |
| 179 | check got.runeLen == 60 |
| 180 | check got.validateUtf8 == -1 |
| 181 | test "leaves a short line alone": |
| 182 | check ch.previewLine("hello there") == "hello there" |
| 183 | |
| 184 | suite "the chats screen": |
| 185 | setup: |
| 186 | var s = initState() |
| 187 | var r = initRoom("#test") |
| 188 | r.joined = true |
| 189 | r.messages = @[Message(frm: "alice", text: "hello")] |
| 190 | s.rooms["#test"] = r |
| 191 | |
| 192 | test "an empty list says so instead of showing nothing": |
| 193 | var empty = initState() |
| 194 | check "No conversations yet — join a channel." in |
| 195 | ch.chatsScreen(empty, false).labels("dim-label") |
| 196 | |
| 197 | test "a room is a card with its name and last line": |
| 198 | let t = ch.chatsScreen(s, true) |
| 199 | check "#test" in t.labels("title-2") |
| 200 | check "alice: hello" in t.labels("dim-label") |
| 201 | |
| 202 | test "the title says who we are when connected": |
| 203 | check "Logged in as frq-guest" in ch.chatsScreen(s, true).labels("title") |
| 204 | check "Chats" in ch.chatsScreen(s, false).labels("title") |
| 205 | |
| 206 | test "an unread count shows, with a mention marked differently": |
| 207 | var u = s.rooms["#test"] |
| 208 | u.unread = 3 |
| 209 | s.rooms["#test"] = u |
| 210 | check "● 3" in ch.chatsScreen(s, true).labels("label") |
| 211 | u.mention = true |
| 212 | s.rooms["#test"] = u |
| 213 | check "◆ @ 3" in ch.chatsScreen(s, true).labels("label") |
| 214 | |
| 215 | test "a channel we are not in is badged, a DM never is": |
| 216 | var away = s.rooms["#test"] |
| 217 | away.joined = false |
| 218 | s.rooms["#test"] = away |
| 219 | check "not joined" in ch.chatsScreen(s, true).labels("status") |
| 220 | var dmr = initRoom("alice") |
| 221 | s.rooms["alice"] = dmr |
| 222 | # Still only the one badge: a DM has nothing to join. |
| 223 | check ch.chatsScreen(s, true).labels("status").countIt(it == "not joined") == 1 |
| 224 | |
| 225 | test "the button says Message for a nick and Join for a channel": |
| 226 | s.joinInput = "#room" |
| 227 | check "Join" in ch.chatsScreen(s, true).labels("button") |
| 228 | s.joinInput = "@alice" |
| 229 | check "Message" in ch.chatsScreen(s, true).labels("button") |
| 230 | |
| 231 | test "the search box offers a clear only when there is something to clear": |
| 232 | check "✕" notin ch.chatsScreen(s, true).labels("button") |
| 233 | s.search = "te" |
| 234 | check "✕" in ch.chatsScreen(s, true).labels("button") |
| 235 | |
| 236 | test "search filters the list": |
| 237 | s.rooms["#other"] = initRoom("#other") |
| 238 | s.search = "oth" |
| 239 | let names = ch.chatsScreen(s, true).labels("title-2") |
| 240 | check names == @["#other"] |