| A face that opens someone 9bb81a1 nandi 21h ago | 1 | ## Who someone is, behind the nick. No network: every case here is a body in |
| 2 | ## and fields out, or a question about a nick. |
| 3 | |
| 4 | import std/[strutils, unicode] |
| 5 | import std/[json, unittest] |
| 6 | import frq/profile |
| 7 | |
| 8 | suite "isHandle": |
| 9 | test "a domain-shaped nick is a handle": |
| 10 | check isHandle("alice.bsky.social") |
| 11 | check isHandle("nandi.uk") |
| 12 | test "a bare nick is not": |
| 13 | # freeq gives an authenticated user their handle by default, while |
| 14 | # `sleek5209` is a guest with no profile. |
| 15 | check not isHandle("sleek5209") |
| 16 | check not isHandle("alice") |
| 17 | test "nor is something with no TLD": |
| 18 | check not isHandle("alice.") |
| 19 | check not isHandle("") |
| A handle is scanned, not matched 32dfa6e nandi 20h ago | 20 | test "a hyphen is fine inside a label, not at its start": |
| 21 | check isHandle("my-host.example.com") |
| 22 | check not isHandle("-host.example.com") |
| 23 | check not isHandle("host.-example.com") |
| 24 | test "the TLD is two or more letters, and only letters": |
| 25 | check not isHandle("alice.c") |
| 26 | check not isHandle("alice.c0m") |
| 27 | check not isHandle("alice.com-") |
| 28 | test "no empty labels": |
| 29 | check not isHandle(".alice.com") |
| 30 | check not isHandle("alice..com") |
| 31 | test "nor anything a domain cannot hold": |
| 32 | check not isHandle("alice bob.com") |
| 33 | check not isHandle("alice@bsky.social") |
| A face that opens someone 9bb81a1 nandi 21h ago | 34 | |
| 35 | suite "actorFor": |
| 36 | test "a DID wins, because it is the identity itself": |
| 37 | # A nick is whatever someone chose today. |
| 38 | check actorFor("did:plc:abc", "alice.bsky.social") == "did:plc:abc" |
| 39 | test "a handle-shaped nick stands in where there is no DID": |
| 40 | check actorFor("", "alice.bsky.social") == "alice.bsky.social" |
| 41 | test "a guest has nothing to look up": |
| 42 | check actorFor("", "sleek5209") == "" |
| 43 | |
| 44 | suite "thumbnailUrl": |
| 45 | test "asks the CDN for the size we paint": |
| 46 | # A 170KB portrait downloaded to draw at 24 points is the thing this |
| 47 | # avoids. |
| 48 | check thumbnailUrl("https://cdn.bsky.app/img/avatar/plain/did:plc:x/y@jpeg") == |
| 49 | "https://cdn.bsky.app/img/avatar_thumbnail/plain/did:plc:x/y@png" |
| 50 | test "replaces an existing format suffix rather than appending": |
| 51 | check thumbnailUrl("https://cdn/x@jpeg").endsWith("@png") |
| 52 | check "@jpeg" notin thumbnailUrl("https://cdn/x@jpeg") |
| 53 | test "no picture is no URL": |
| 54 | check thumbnailUrl("") == "" |
| 55 | |
| 56 | suite "parseProfile": |
| 57 | let body = parseJson("""{ |
| 58 | "did": "did:plc:abc", "handle": "alice.bsky.social", |
| 59 | "displayName": " Alice ", "description": " hello ", |
| 60 | "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:abc/p@jpeg", |
| 61 | "followersCount": 12, "followsCount": 34, "postsCount": 56 }""") |
| 62 | |
| 63 | test "the fields the panel paints": |
| 64 | let p = parseProfile(body) |
| 65 | check p.status == psReady |
| 66 | check p.did == "did:plc:abc" |
| 67 | check p.handle == "alice.bsky.social" |
| 68 | check p.displayName == "Alice" # trimmed |
| 69 | check p.description == "hello" |
| 70 | check p.followers == 12 |
| 71 | check p.avatar.endsWith("@png") |
| 72 | |
| 73 | test "a body with nothing in it": |
| 74 | let p = parseProfile(parseJson("{}")) |
| 75 | check p.did == "" |
| 76 | check p.avatar == "" |
| 77 | |
| 78 | suite "statsLine": |
| 79 | test "all three": |
| 80 | let p = Profile(followers: 12, follows: 34, posts: 56) |
| 81 | check statsLine(p) == "12 followers · 34 following · 56 posts" |
| 82 | test "only what is known": |
| 83 | check statsLine(Profile(posts: 5)) == "5 posts" |
| 84 | test "none is no line at all": |
| 85 | check statsLine(Profile()) == "" |
| 86 | |
| 87 | suite "webUrl": |
| 88 | test "by handle where there is one": |
| 89 | check webUrl(Profile(handle: "alice.bsky.social", did: "did:plc:x")) == |
| 90 | "https://bsky.app/profile/alice.bsky.social" |
| 91 | test "by DID otherwise": |
| 92 | check webUrl(Profile(did: "did:plc:x")) == "https://bsky.app/profile/did:plc:x" |
| 93 | test "a leading @ is not part of a handle": |
| 94 | check webUrl(Profile(handle: "@alice.bsky.social")) == |
| 95 | "https://bsky.app/profile/alice.bsky.social" |
| 96 | test "nobody is no URL": |
| 97 | check webUrl(Profile()) == "" |
| 98 | |
| 99 | suite "truncate": |
| 100 | test "leaves a short bio alone, line breaks and all": |
| 101 | # The height of a multi-line bio is part of what it says. |
| 102 | check truncate("one\ntwo", 280) == "one\ntwo" |
| 103 | test "cuts a long one": |
| 104 | let got = truncate("x".repeat(400), 280) |
| 105 | check got.runeLen == 280 |
| 106 | check got.endsWith("…") |
| 107 | |
| 108 | test "cuts by character, not by byte": |
| 109 | let got = truncate("😀".repeat(400), 280) |
| 110 | check got.runeLen == 280 |
| 111 | check got.validateUtf8 == -1 |