Spaces:
Paused
Paused
File size: 12,761 Bytes
65651b1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 | """The channel chooser: names written out as a numbered list (A to Z, 20 to a page), buttons that carry only numbers
(5 to a row), โ โถ underneath - and every command that needs a channel uses it."""
import asyncio
from types import SimpleNamespace
from app import picker
from app.picker import PER_PAGE, PER_ROW, names_of, natural_key, ordered, render
from .harness import App, norm
def run(coro):
return asyncio.run(coro)
def chan(i, title, username=None):
return SimpleNamespace(id=i, title=title, username=username)
def data_of(btn):
t = btn.type if hasattr(btn, "type") else btn
return t.data.decode()
def lines_of(text):
"""The numbered lines of a message: ['1. Naruto', ...]."""
return [ln for ln in norm(text).splitlines() if ln[:1].isdigit() and ". " in ln[:5]]
# ------------------------------------------------------------------------------------------ the list itself
def test_names_are_sorted_a_to_z_ignoring_case_and_numbers_count_by_value():
chans = [chan(1, "One piece"), chan(2, "naruto"), chan(3, "Dragon ball"), chan(4, "Anime 10"), chan(5, "Anime 2")]
assert [c.title for c in ordered(chans)] == ["Anime 2", "Anime 10", "Dragon ball", "naruto", "One piece"]
assert natural_key(" Anime 2 ") == natural_key("anime 2")
# the same name twice: the id decides, so the order never changes between two looks
assert [c.id for c in ordered([chan(9, "Same"), chan(4, "same")])] == [4, 9]
def test_the_message_lists_the_names_and_the_buttons_only_carry_numbers():
chans = [chan(10, "One piece"), chan(20, "Naruto"), chan(30, "Dragon ball")]
text, rows = render(chans, 0, head="Which one?", kind="x", choose=lambda c: f"go:{c.id}")
assert lines_of(text) == ["1. Dragon ball", "2. Naruto", "3. One piece"]
assert [[b.text for b in row] for row in rows] == [["1", "2", "3"]]
# pressing 3 means One piece - the button carries the channel's id, not its place in the list
assert [data_of(b) for b in rows[0]] == ["go:30", "go:20", "go:10"]
assert text.startswith("Which one?") and "Tap the number of the channel." in text
assert "Page" not in text # one page: no page line, no arrows
def test_a_row_has_at_most_five_buttons_and_the_extra_rows_come_last():
chans = [chan(i, f"Channel {i:02d}") for i in range(1, 13)]
text, rows = render(chans, 0, head="h", kind="x", choose=lambda c: f"go:{c.id}", extra_rows=[["cancel-row"]])
assert [len(r) for r in rows[:-1]] == [5, 5, 2]
assert rows[-1] == ["cancel-row"]
assert PER_ROW == 5 and PER_PAGE == 20
def test_twenty_names_to_a_page_with_arrows_that_wrap_around():
chans = [chan(i, f"Channel {i:02d}") for i in range(1, 46)]
text0, rows0 = render(chans, 0, head="h", kind="x", arg="tok", choose=lambda c: f"go:{c.id}", extra_rows=[["cancel-row"]])
assert [ln.split(". ")[0] for ln in lines_of(text0)] == [str(n) for n in range(1, 21)]
assert "Page 1 of 3 - channels 1 to 20 of 45" in norm(text0)
assert [[b.text for b in r] for r in rows0[:4]] == [[str(n) for n in range(s, s + 5)] for s in (1, 6, 11, 16)]
assert [b.text for b in rows0[4]] == ["โ", "โถ"] # the arrows sit under the number buttons
assert rows0[5] == ["cancel-row"]
assert data_of(rows0[4][1]) == "cpg:x:tok:1" and data_of(rows0[4][0]) == "cpg:x:tok:2" # โ on the first page = the last page
text1, rows1 = render(chans, 1, head="h", kind="x", arg="tok", choose=lambda c: f"go:{c.id}")
assert lines_of(text1)[0] == "21. Channel 21" and lines_of(text1)[-1] == "40. Channel 40"
assert [[b.text for b in r] for r in rows1[:4]] == [[str(n) for n in range(s, s + 5)] for s in (21, 26, 31, 36)]
assert data_of(rows1[0][0]) == "go:21" and data_of(rows1[3][4]) == "go:40"
assert data_of(rows1[4][0]) == "cpg:x:tok:0" and data_of(rows1[4][1]) == "cpg:x:tok:2"
text2, rows2 = render(chans, 2, head="h", kind="x", arg="tok", choose=lambda c: f"go:{c.id}")
assert lines_of(text2) == [f"{n}. Channel {n:02d}" for n in range(41, 46)]
assert [[b.text for b in r] for r in rows2[:1]] == [["41", "42", "43", "44", "45"]]
assert data_of(rows2[1][1]) == "cpg:x:tok:0" # โถ on the last page = the first page
# a page number that no longer exists (channels were removed meanwhile) shows the last page
assert lines_of(render(chans, 99, head="h", kind="x", choose=lambda c: "go")[0])[0] == "41. Channel 41"
def test_a_list_to_read_has_no_number_buttons_but_still_pages():
chans = [chan(i, f"Channel {i:02d}") for i in range(1, 26)]
text, rows = render(chans, 0, head="h", kind="ch", choose=None)
assert [[b.text for b in r] for r in rows] == [["โ", "โถ"]]
assert "Tap the number" not in text
assert render(chans[:3], 0, head="h", kind="ch", choose=None)[1] == []
def test_equal_names_get_the_username_or_id_and_odd_names_are_safe():
chans = [chan(1, "Anime", "animech"), chan(2, "ANIME"), chan(3, "Other", "otherch")]
assert names_of(ordered(chans)) == ["Anime (@animech)", "ANIME (2)", "Other"]
assert names_of(ordered(chans), with_username=True) == ["Anime (@animech)", "ANIME (2)", "Other (@otherch)"]
long = chan(4, "x" * 200)
assert len(names_of([long])[0]) == picker.NAME_MAX
text, _ = render([chan(5, "<b>Bold & co</b>")], 0, head="h", kind="x", choose=lambda c: "go")
assert "<b>Bold & co</b>" in text and "<b>Bold" not in text.split("\n\n", 1)[1]
assert names_of([chan(6, "")]) == ["Channel"]
# ------------------------------------------------------------------------------ the commands that use it
async def make_app(tmp_path, n, *, owner=1, **cfg):
app = App(tmp_path, owner=owner, edit_delay=0, **cfg)
await app.start()
for i in reversed(range(1, n + 1)): # saved in reverse: the list has to sort them
await app.db.save_channel(i, 5, f"Channel {i:02d}", f"ch{i:02d}", 1)
return app
def test_new_lists_the_channels_and_a_number_starts_a_post_for_that_channel(tmp_path):
async def go():
app = await make_app(tmp_path, 45)
await app.text("/new")
text = norm(app.out.last_text)
assert "Which channel is this post for?" in text
assert lines_of(app.out.last_text)[0] == "1. Channel 01" and len(lines_of(app.out.last_text)) == 20
assert [len(r) for r in app.out.last_buttons()] == [5, 5, 5, 5, 2, 1] # 4 rows of numbers, the arrows, Cancel
await app.press(app.out.callback_exact("โถ")) # edits the same message
assert lines_of(app.out.last_text)[0] == "21. Channel 21" and lines_of(app.out.last_text)[-1] == "40. Channel 40"
assert app.out.last_buttons()[0] == ["21", "22", "23", "24", "25"]
await app.press(app.out.callback_exact("โถ"))
assert lines_of(app.out.last_text) == [f"{n}. Channel {n:02d}" for n in range(41, 46)]
await app.press(app.out.callback_exact("โถ")) # wraps round to the first page
assert lines_of(app.out.last_text)[0] == "1. Channel 01"
await app.press(app.out.callback_exact("โ")) # ... and back to the last one
assert lines_of(app.out.last_text)[0] == "41. Channel 41"
await app.press(app.out.callback_exact("43"))
assert app.ctx.state[1]["mode"] == "new_content" and app.ctx.state[1]["cid"] == 43
await app.db.close()
run(go())
def test_new_with_a_single_channel_still_goes_straight_to_the_post(tmp_path):
async def go():
app = await make_app(tmp_path, 1)
await app.text("/new")
assert app.ctx.state[1]["cid"] == 1 and "Send the post now" in norm(app.out.last_text)
await app.db.close()
run(go())
def test_posts_offers_the_channels_and_the_drafts(tmp_path):
async def go():
app = await make_app(tmp_path, 25)
await app.text("/posts")
assert "My posts" in norm(app.out.last_text) and lines_of(app.out.last_text)[0] == "1. Channel 01"
assert app.out.last_buttons()[-1] == ["๐พ Drafts (all channels)"]
assert app.out.callback_exact("๐พ Drafts (all channels)") == "pc:d:0"
assert app.out.callback_exact("3") == "pc:3:0"
await app.press(app.out.callback_exact("โถ"))
assert lines_of(app.out.last_text) == [f"{n}. Channel {n:02d}" for n in range(21, 26)]
assert app.out.last_buttons()[-1] == ["๐พ Drafts (all channels)"]
await app.press(app.out.callback_exact("24"))
assert "Channel 24" in norm(app.out.last_text) and "post(s)" in norm(app.out.last_text)
await app.press("pl") # ๐ Channels
assert lines_of(app.out.last_text)[0] == "1. Channel 01"
await app.db.close()
run(go())
def test_posts_without_a_channel_still_offers_the_drafts(tmp_path):
async def go():
app = await make_app(tmp_path, 0)
await app.text("/posts")
assert "No channels yet" in norm(app.out.last_text) and app.out.callback_exact("๐พ Drafts (all channels)") == "pc:d:0"
await app.db.close()
run(go())
def test_channels_owner_taps_a_number_and_confirms_before_a_channel_is_removed(tmp_path):
async def go():
app = await make_app(tmp_path, 3)
await app.text("/channels")
assert lines_of(app.out.last_text) == ["1. Channel 01 (@ch01)", "2. Channel 02 (@ch02)", "3. Channel 03 (@ch03)"]
assert app.out.last_buttons() == [["1", "2", "3"]]
await app.press(app.out.callback_exact("2"))
assert "Take Channel 02 out of the bot?" in norm(app.out.last_text)
assert [c.id for c in await app.db.list_channels()] == [1, 2, 3] # nothing is removed by the number alone
await app.press(app.out.callback_exact("๐ Back"))
assert lines_of(app.out.last_text)[1] == "2. Channel 02 (@ch02)"
await app.press(app.out.callback_exact("2"))
await app.press(app.out.callback_exact("โ
Yes, remove it"))
assert "removed from the bot" in norm(app.out.last_text)
assert [c.id for c in await app.db.list_channels()] == [1, 3]
await app.db.close()
run(go())
def test_channels_for_an_admin_is_only_a_list(tmp_path):
async def go():
app = await make_app(tmp_path, 3, owner=7, admins=frozenset({1}))
app.uid = 1 # an admin, not an owner
await app.text("/channels")
assert lines_of(app.out.last_text)[0] == "1. Channel 01 (@ch01)" and app.out.last_buttons() == []
assert "tap its number" not in norm(app.out.last_text)
await app.press("chd:2") # a made-up press does nothing for a non-owner
assert [c.id for c in await app.db.list_channels()] == [1, 2, 3]
await app.db.close()
run(go())
def test_autopost_and_repost_page_through_the_channels_and_a_number_picks_one(tmp_path):
async def go():
app = await make_app(tmp_path, 45)
await app.text("/autopost 20 10")
assert "Which channel should I post them in?" in norm(app.out.last_text) and "2 post(s)" in norm(app.out.last_text)
assert lines_of(app.out.last_text)[0] == "1. Channel 01"
await app.press(app.out.callback_exact("โถ"))
assert "2 post(s)" in norm(app.out.last_text) and lines_of(app.out.last_text)[0] == "21. Channel 21" # same question
assert app.out.callback_exact("21").startswith("apc:") and app.out.callback_exact("21").endswith(":21")
assert app.out.last_buttons()[-1] == ["โ๏ธ Cancel"]
await app.text("/repost")
await app.text("/repost --last 3")
assert "Which channel should I repost?" in norm(app.out.last_text) and "newest 3" in norm(app.out.last_text)
await app.press(app.out.callback_exact("โถ"))
await app.press(app.out.callback_exact("โถ"))
assert lines_of(app.out.last_text)[0] == "41. Channel 41" and "newest 3" in norm(app.out.last_text)
data = app.out.callback_exact("45")
assert data.startswith("rpch:") and data.endswith(":45")
await app.db.close()
run(go())
def test_an_old_arrow_of_a_list_that_has_expired_says_so(tmp_path):
async def go():
app = await make_app(tmp_path, 25)
await app.press("cpg:rp:deadbeef:1")
assert "expired" in norm(app.out.last_text)
await app.press("cpg:ap:deadbeef:1")
assert "expired" in norm(app.out.last_text)
await app.press("cpg:zz::1")
assert "no longer active" in norm(app.out.last_text)
await app.db.close()
run(go())
def test_the_channel_pickers_of_the_owner_tools_refuse_everybody_else(tmp_path):
async def go():
app = await make_app(tmp_path, 25, owner=7, admins=frozenset({1}))
app.uid = 1
await app.press("cpg:rp:deadbeef:1")
assert "Only the bot owner" in norm(app.out.last_text)
await app.db.close()
run(go())
|