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 "&lt;b&gt;Bold &amp; co&lt;/b&gt;" 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())