From 3dee25bd80046b964f7ad0fa91b12c478c08a41f Mon Sep 17 00:00:00 2001 From: Collin R Date: Sat, 1 Feb 2020 00:23:15 -0800 Subject: [PATCH] Reworked comment & progress bar --- .gitignore | 5 +- README.md | 110 +--------------------- pointsbot/bot.py | 211 ++++++++++++++++++++++++------------------ pointsbot/comment.py | 92 ------------------ pointsbot/config.py | 55 +++++++++++ pointsbot/level.py | 66 +++++++++++++ pointsbot/reply.py | 124 +++++++++++++++++++++++++ tests/context.py | 5 + tests/test_comment.py | 15 --- tests/test_level.py | 40 ++++++++ tests/test_reply.py | 44 +++++++++ 11 files changed, 464 insertions(+), 303 deletions(-) delete mode 100644 pointsbot/comment.py create mode 100644 pointsbot/config.py create mode 100644 pointsbot/level.py create mode 100644 pointsbot/reply.py create mode 100644 tests/context.py delete mode 100644 tests/test_comment.py create mode 100644 tests/test_level.py create mode 100644 tests/test_reply.py diff --git a/.gitignore b/.gitignore index 6498e57..4c8e409 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ praw.ini pointsbot.ini -todo.md -*.db +*.db *.pyc __pycache__ +docs/ +*.out *.swp diff --git a/README.md b/README.md index f27617d..ad2e9a7 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,6 @@ * [Installation](#installation) * [Configuration](#configuration) * [Usage](#usage) -* [Ideas](#ideas) -* [Questions](#questions) * [Terms of Use for a bot for Reddit](#terms-of-use-for-a-bot-for-reddit) * [License](#license) @@ -16,17 +14,13 @@ This is a bot for Reddit that monitors solutions to questions or problems in a subreddit and awards points to the user responsible for the solution. -This bot is intended as a solution for +This bot was conceived as a response to [this request](https://www.reddit.com/r/RequestABot/comments/emdeim/expert_level_bot_coding/). The bot will award a point to a redditor when the OP of a submission includes "!Solved" or "!solved" somewhere in a reply to the redditor's comment on that submission. These points will allow the redditor to advance to different -levels: - -* Helper (5 points) -* Trusted Helper (15 points) -* Super Helper (40 points) +levels. At each level, the redditor's flair for the subreddit will be updated to reflect their current level. However, the bot should not change a mod's flair. @@ -35,6 +29,9 @@ Each time a point is awarded, the bot will reply to the solution comment to notify the redditor of their total points, with a progress bar to show how many points they need to reach the next level and a reminder of the title of the next level. +In order to prevent the progress bar from being excessively long, some points +will be consolidated into stars instead. Right now, stars are hard-coded to +represent 100 points each, but this behavior may be configurable in the future. The first time a point is awarded, the bot's reply comment will also include a brief message detailing the points system. @@ -171,103 +168,6 @@ run: pipenv run python -m pointsbot ``` -## Ideas - -### Config - -* Store all config and data in a hidden `.pointsbot` directory or similar in the - user's home or data directory (OS-dependent). -* Could do something similar to packages like PRAW: - 1. Look in current working directory first. - 2. Look in OS-dependent location next. - 3. (Maybe) Finally, could look in the directory containing the source files - (using `__file__`). -* Could also allow use of environment variables, but this seems unnecessary and - could be a little too technical (though any more technically-minded users - might appreciate the option). -* Consolidate `pointsbot.ini` and `praw.ini` into a single config file. -* Write a CLI script or GUI to handle this so the config values don't have to be - changed manually. - -### Database - -* Should it keep track of the solved posts for future reference and calculate - points on the fly, rather than just keeping track of points? If so, should - have a column denoting whether the post has been deleted, if that - information is decided to be useful when determining a user's points. - -### Determining when to award points - -To ensure that points are only awarded for the first comment marked as a -solution: - -* Alter the database to allow for tracking of each submission and the first - comment marked as the solution. Then, everytime a new solution comment is - detected, simply check the database to see if the submission is already - counted. This will avoid unnecessary calls to Reddit, which would include - scanning all the submission comments each time. -* This approach could also make it simpler to check whether a solution comment - has been edited. Instead of having to do a daily search for edits, it could - just check the original solution comment to ensure that it still contains - the "!solved" string. If not, it can remove points from that author and - award points to the new author. - -To ensure that a point is awarded to the correct user: - -* We could expand the "!solved" bot summons to include an optional username - argument. Without the argument, the bot will award the point to either the - top-level comment or the parent comment of the "!solved" comment, whichever - is decided upon. However, if the username argument is provided, the bot - could simple check that one of the comments in the comment tree belongs to - that user, and then award them the point. - - Honestly, this is probably overcomplicated and unnecessary, though. - -## Questions - -1. Should it really display a progress bar without a bounds, since the user could - get a large amount of points? Or should it end at 40 or whatever the max - level is? -1. When replying to a solution, should the bot... - 1. Reply directly to the comment containing the solution (current behavior), - or - 1. Reply to the comment marking the submission as "!solved" and tag the - solver? -1. Should the bot check whether comments containing "!solved" have been edited to - remove it? - - If so, it could do that daily or something. - - This will be especially important if a "recovery mode" is implemented that - crawls through the whole subreddit to rebuild the database, since the - bot would only be able to see comments that haven't been removed, or - the newest version of edited comments. -1. Should the bot be prepared to handle complex comment threads (e.g. multiple - replies to the comment before it is marked as solved)? In other words, when - the bot finds a comment containing the "!solved" string, should it... - 1. Assume that the author of the top-level comment is earning the point? - 1. Assume that the author of the parent comment of the "!solved" comment - is earning the point? (current behavior) - - Currently, the bot will either award the point to the author of the - parent comment to the "!solved" comment, unless the author is the - submission's OP, in which case is simply ignores the "!solved" - comment altogether. - - If we go with this behavior, I will probably change it to start at the - parent of the "!solved" comment and work its way up until it finds a - comment author who is not the OP, and then award them the point. - - A workaround for this is to ask users to provide a username following - the "!solved" word, starting with "r/" of course to make it easy to - identify. Then, in situations where the bot is unable to determine - who earned the point and the OP didn't provide a username after - "!solved", the bot could even reply to the OP's "!solved" comment - and request the username of the solver. -1. Should the bot still keep track of points for mods, even though it doesn't - update their flair? (I'd assume so, but it doesn't hurt to ask). -1. Should the bot's reply comment always tag the user earning the point, even if - it's responding directly to their comment? (Currently, it only tags the user - when they earn their first point.) -1. When a user levels up, should the reply comment also mention that they have - been awarded a new flair? -1. Should the comment contain a notice that the post was made by a bot, similar - to the notice on posts by automod? - ## Terms of Use for a bot for Reddit Since this is an open-source, unmonetized program, it should be considered diff --git a/pointsbot/bot.py b/pointsbot/bot.py index b5b3aaa..ab4fa12 100644 --- a/pointsbot/bot.py +++ b/pointsbot/bot.py @@ -1,136 +1,169 @@ -import configparser import re import praw -from . import comment, database - -### Globals ### - -CONFIGPATH = 'pointsbot.ini' +from . import config, database, level, reply ### Main Function ### def run(): - config = configparser.ConfigParser() - config.read(CONFIGPATH) - - # Get the user flair levels in ascending order by point value - # TODO Make levels a dict instead - # TODO Could also make a Level class or namedtuple that contains more info, e.g. - # flair css or template id - - # levels = [(o, config.getint('Levels', o)) for o in config.options('Levels')] - # levels.sort(key=lambda pair: pair[1]) - - levels = [] - for opt in config.options('Levels'): - levels.append((opt.title(), config.getint('Levels', opt))) - levels.sort(key=lambda pair: pair[1]) + cfg = config.load() + levels = cfg.levels # Connect to Reddit - reddit = praw.Reddit(site_name=config['Core']['praw_site_name']) - subreddit = reddit.subreddit(config['Core']['subreddit_name']) + reddit = praw.Reddit(site_name=cfg.praw_site_name) + subreddit = reddit.subreddit(cfg.subreddit_name) - print(f'Connected to Reddit as {reddit.user.me()}') - print(f'Read-only? {reddit.read_only}') - print(f'Watching subreddit {subreddit.title}') - print(f'Is mod? {bool(subreddit.moderator(redditor=reddit.user.me()))}') + print_level(0, f'Connected to Reddit as {reddit.user.me()}') + print_level(1, f'Read-only? {reddit.read_only}') + print_level(0, f'Watching subreddit {subreddit.title}') + is_mod = bool(subreddit.moderator(redditor=reddit.user.me())) + print_level(1, f'Is mod? {is_mod}') - # TODO pass database path instead of setting global variable - db = database.Database(config['Core']['database_name']) + testpoints = [1, 3, 5, 10, 15, 30, 45, 75] + list(range(100, 551, 50)) - # The pattern to look for in comments when determining whether to award a point - # solved_pat = re.compile('!solved', re.IGNORECASE) + for sub in subreddit.new(): + if sub.title == 'Testing comment scenarios': + redditor = sub.author + for points in testpoints: + body = f'Solver: {redditor}\n\nTotal points after solving: {points}' + print_level(0, body) + comm = sub.reply(body) + if comm: + level_info = level.user_level_info(points, levels) + body = reply.make(redditor, points, level_info) + comm.reply(body) + else: + print_level(1, 'ERROR: Unable to comment') + break + + +def real_run(): + cfg = config.load() + levels = cfg.levels + + # Connect to Reddit + reddit = praw.Reddit(site_name=cfg.praw_site_name) + subreddit = reddit.subreddit(cfg.subreddit_name) + + print_level(0, f'Connected to Reddit as {reddit.user.me()}') + print_level(1, f'Read-only? {reddit.read_only}') + print_level(0, f'Watching subreddit {subreddit.title}') + is_mod = bool(subreddit.moderator(redditor=reddit.user.me())) + print_level(1, f'Is mod? {is_mod}') + + db = database.Database(cfg.database_path) + + # The pattern that determines whether a post is marked as solved + # Could also just use re.IGNORECASE flag solved_pat = re.compile('![Ss]olved') # Monitor new comments for confirmed solutions - for comm in subreddit.stream.comments(skip_existing=True): - print('Found comment') - print(f'Comment text: "{comm.body}"') + # Passing pause_after=0 will bypass the internal exponential delay, but have + # to check if any comments are returned with each query + for comm in subreddit.stream.comments(skip_existing=True, pause_after=0): + if comm is None: + continue + + print_level(0, '\nFound comment') + print_level(1, f'Comment text: "{comm.body}"') if marks_as_solved(comm, solved_pat): - # Ensure that this is the first "!solved" comment in the thread - submission = comm.submission - # Retrieve any comments hidden by "more comments" - submission.comments.replace_more(limit=0) - - # Search the flattened comments tree - is_first_solution = True - for subcomm in submission.comments.list(): - if (subcomm.id != comm.id - and marks_as_solved(subcomm, solved_pat) - and subcomm.created_utc < comm.created_utc): - # There is an earlier comment for the same submission - # already marked as a solution by the OP - is_first_solution = False - break - - if not is_first_solution: + if not is_first_solution(comm, solved_pat): # Skip this "!solved" comment and wait for the next - print('This is not the first solution') + print_level(1, 'Not the first solution') continue - print('This is the first solution') + print_level(1, 'This is the first solution') print_solution_info(comm) - solution = comm.parent() - solver = solution.author - print(f'Adding point for {solver.name}') + solver = comm.parent().author + print_level(1, f'Adding point for {solver.name}') db.add_point(solver) points = db.get_points(solver) - print(f'Points for {solver.name}: {points}') + print_level(1, f'Points for {solver.name}: {points}') - # Reply to the comment containing the solution - reply_body = comment.make(solver, points, levels) - print(f'Replying with: "{reply_body}"') - solution.reply(reply_body) + level_info = level.user_level_info(points, levels) + + # TODO move comment to the end and use some things, e.g. whether + # flair is set, when building comment (to avoid duplicate logic) + + # Reply to the comment marking the submission as solved + reply_body = reply.make(solver, points, level_info) + # reply_body = reply.make(solver, points, levels) + print_level(1, f'Replying with: "{reply_body}"') + comm.reply(reply_body) # Check if (non-mod) user flair should be updated to new level - for levelname, levelpoints in levels: - # If the redditor's points total is equal to one of the levels, - # that means they just reached that level - if points == levelpoints: - print('User reached new level') - if not subreddit.moderator(redditor=solver): - # TODO can also use the keyword arg css_class *or* - # flair_template_id to style the flair - print(f'Setting flair text to {levelname}') - subreddit.flair.set(solver, text=levelname) - else: - print('Don\'t update flair b/c user is mod') - - # Don't need to check the rest of the levels - break + if level_info.current and level_info.current.points == points: + print_level(1, f'User reached level: {level_info.current.name}') + if not subreddit.moderator(redditor=solver): + print_level(2, 'Setting flair') + print_level(3, f'Flair text: {level_info.current.name}') + # TODO + # print_level(3, f'Flair template ID: {}') + subreddit.flair.set(solver, text=levelname) + else: + print_level(2, 'Solver is mod; don\'t alter flair') else: - print('Not a "!solved" comment') + print_level(1, 'Not a "!solved" comment') -### Auxiliary Functions ### +### Reddit Comment Functions ### def marks_as_solved(comment, solved_pattern): - '''Return True if not top-level comment, from OP, contains "!Solved"; False - otherwise. + '''Return True if the comment meets the criteria for marking the submission + as solved, False otherwise. ''' - #comment.refresh() # probably not needed, but the docs are a tad unclear return (not comment.is_root and comment.is_submitter and not comment.parent().is_submitter and solved_pattern.search(comment.body)) -### Debugging ### +def is_first_solution(solved_comment, solved_pattern): + # Retrieve any comments hidden by "more comments" + # Passing limit=0 will replace all "more comments" + submission = solved_comment.submission + submission.comments.replace_more(limit=0) + + # Search the flattened comments tree + for comment in submission.comments.list(): + if (comment.id != solved_comment.id + and marks_as_solved(comment, solved_pattern) + and comment.created_utc < solved_comment.created_utc): + # There is an earlier comment for the same submission + # already marked as a solution by the OP + return False + return True + + +def find_solver(comment): + # TODO + pass + + +def make_flair(points, levels): + # TODO + pass + + +### Debugging & Logging ### + + +def print_level(num_levels, string): + print('\t' * num_levels + string) def print_solution_info(comm): - print('Submission solved') - print('\tSolution comment:') - print(f'\t\tAuthor: {comm.parent().author.name}') - print(f'\t\tBody: {comm.parent().body}') - print('\t"Solved" comment:') - print(f'\t\tAuthor: {comm.author.name}') - print(f'\t\tBody: {comm.body}') + print_level(1, 'Submission solved') + print_level(2, 'Solution comment:') + print_level(3, f'Author: {comm.parent().author.name}') + print_level(3, f'Body: {comm.parent().body}') + print_level(2, '"Solved" comment:') + print_level(3, f'Author: {comm.author.name}') + print_level(3, f'Body: {comm.body}') diff --git a/pointsbot/comment.py b/pointsbot/comment.py deleted file mode 100644 index 056c37e..0000000 --- a/pointsbot/comment.py +++ /dev/null @@ -1,92 +0,0 @@ -### Globals ### - -FILLED_SYMBOL = '\u25AE' # A small filled box character -EMPTY_SYMBOL = '\u25AF' # A same-sized empty box character -DIV_SYMBOL = '|' - -### Main Functions ### - - -def make(redditor, points, levels): - body = (first_point(redditor) + '\n\n') if points == 1 else '' - body += points_status(redditor, points, levels) - return body - - -### Auxiliary Functions ### - - -def first_point(redditor): - msg = (f'Congrats, u/{redditor.name}; you have received a point! Points ' - 'help you "level up" to the next user flair!') - return msg - - -def points_status(redditor, points, levels): - '''Levels is an iterable of (level_name, points) pairs, sorted in ascending - order by points. - ''' - for next_level_name, next_level_points in levels: - if next_level_points > points: - break - - pointstext = 'points' if points > 1 else 'point' - - if points < next_level_points: - lines = [ - f'Next level: "{next_level_name}"', - f'You have {points} {pointstext}', - f'You need {next_level_points} points', - ] - else: - lines = [ - 'MAXIMUM LEVEL ACHIEVED!!!', - f'You have {points} {pointstext}', - ] - - # 2 spaces are appended to each line to force a line break but not a - # paragraph break - # lines = [line + ' ' for line in lines] - lines = list(map(lambda line: line + ' ', lines)) - - """ - if points < next_level_points: - lines = [ - f'Next level: "{next_level_name}" ', - f'You need {next_level_points} points ', - ] - else: - lines = ['MAXIMUM LEVEL ACHIEVED!!! '] - - # TODO hacky and bad :( - lines.insert(1, f'You have {points} points ') - """ - - lines.append(progress_bar(points, levels)) - - return '\n'.join(lines) - - -def progress_bar(points, levels): - '''Assumes levels is sorted in ascending order.''' - progbar = [FILLED_SYMBOL] * points - ndx_shift = 0 - for levelndx, level in enumerate(levels): - next_level_name, next_level_points = level - if next_level_points > points: - break - ndx = next_level_points + ndx_shift - progbar.insert(ndx, DIV_SYMBOL) - ndx_shift += 1 - - if next_level_points <= points: - # If just reached max level, then an extra DIV_SYMBOL was appended - progbar.pop() - else: - # Not max level, so fill slots left until next level with EMPTY_SYMBOL - remaining = next_level_points - points - progbar.extend([EMPTY_SYMBOL] * remaining) - - return '[' + ''.join(progbar) + ']' - - diff --git a/pointsbot/config.py b/pointsbot/config.py new file mode 100644 index 0000000..56492f8 --- /dev/null +++ b/pointsbot/config.py @@ -0,0 +1,55 @@ +import configparser +import os.path +# from os.path import abspath, dirname, join + +from .level import Level + +### Globals ### + +ROOTPATH = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +CONFIGPATH = os.path.join(ROOTPATH, 'pointsbot.ini') +# CONFIGPATH = abspath(join(dirname(__file__), '..', 'pointsbot.ini')) + +# TODO add default config values to pass to configparser + +### Classes ### + + +class Config: + + def __init__(self, praw_site_name='', subreddit_name='', database_path='', + levels=None): + self.praw_site_name = praw_site_name + self.subreddit_name = subreddit_name + self.database_path = database_path + self.levels = levels if levels is not None else [] + + @classmethod + def from_configparser(cls, config): + database_path = os.path.join(ROOTPATH, config['Core']['database_name']) + + # Get the user flair levels in ascending order by point value + # TODO Make levels a dict instead + levels = [] + for opt in config.options('Levels'): + name, points = opt.title(), config.getint('Levels', opt) + levels.append(Level(name, points)) + levels.sort(key=lambda lvl: lvl.points) + + return cls( + praw_site_name=config['Core']['praw_site_name'], + subreddit_name=config['Core']['subreddit_name'], + database_path=database_path, + levels=levels, + ) + + +### Functions ### + + +def load(): + config = configparser.ConfigParser() + config.read(CONFIGPATH) + return Config.from_configparser(config) + + diff --git a/pointsbot/level.py b/pointsbot/level.py new file mode 100644 index 0000000..f4cbef4 --- /dev/null +++ b/pointsbot/level.py @@ -0,0 +1,66 @@ +from collections import namedtuple + +### Data Structures ### + +# A (string, int) tuple +Level = namedtuple('Level', 'name points') + +# A ([Level], Level, Level) tuple; +# previous can be empty, and exactly one of current and next can be None +LevelInfo = namedtuple('LevelInfo', 'previous current next') +# LevelInfo = namedtuple('LevelInfo', 'prev cur next') + +### Functions ### + + +""" +def get_levels(config): + levels = [] + for opt in config.options('Levels'): + name, points = opt.title(), config.getint('Levels', opt) + levels.append(Level(name, points)) + # levels.append((opt.title(), config.getint('Levels', opt))) + # levels.sort(key=lambda pair: pair[1]) + levels.sort(key=lambda lvl: lvl.points) + return levels +""" + + +def user_level_info(points, levels): + '''Return a tuple the user's previous (plural), current, and next levels. + + If the user has yet to reach the first level, return ([], None, ). + If the user has reached the max level, return ([previous], , + None). + + Assume levels is sorted in ascending order by points. + ''' + past_levels, cur_level, next_level = [], None, None + + for level in levels: + lvlname, lvlpoints = level + if points < lvlpoints: + next_level = level + break + if cur_level: + past_levels.append(cur_level) + cur_level = level + else: + next_level = None + + return LevelInfo(past_levels, cur_level, next_level) + + +def is_max_level(level_info): + return not level_info.next + + +""" +def is_max_level(points, levels): + '''Assume levels is sorted in ascending order by points.''' + # return points >= levels[-1][1] + return points >= levels[-1].points +""" + + diff --git a/pointsbot/reply.py b/pointsbot/reply.py new file mode 100644 index 0000000..78ee4a0 --- /dev/null +++ b/pointsbot/reply.py @@ -0,0 +1,124 @@ +from . import level + +### Globals ### + +EMPTY_SYMBOL = '\u25AF' # A same-sized empty box character +FILLED_SYMBOL = '\u25AE' # A small filled box character +EXCESS_SYMBOL = '\u2B51' # A star character +DIV_SYMBOL = '|' + +# Number of "excess" points should be greater than max level points +EXCESS_POINTS = 100 # TODO move this to level? + +### Main Functions ### + + +def make(redditor, points, level_info): + paras = [header()] + if points == 1: + paras.append(first_greeting(redditor)) + if level_info.current and points == level_info.current.points: + paras.append(level_up(redditor, + level_info.current.name, + tag_user=False)) + elif points > 1: + if level_info.current and points == level_info.current.points: + paras.append(level_up(redditor, level_info.current.name)) + elif not level_info.next and points > 0 and points % EXCESS_POINTS == 0: + first_star = (points == EXCESS_POINTS) + paras.append(new_star(redditor, first_star)) + else: + paras.append(normal_greeting(redditor)) + + paras.append(points_status(redditor, points, level_info)) + paras.append(footer()) + return '\n\n'.join(paras) + + +### Comment Section Functions ### + + +def header(): + return 'Thanks! Post marked as Solved!' + + +def first_greeting(redditor): + msg = (f'Congrats, u/{redditor.name}, you have received a point! Points ' + 'help you "level up" to the next user flair!') + return msg + + +def normal_greeting(redditor): + return f'u/{redditor.name}, here is your points status:' + + +def level_up(redditor, level_name, tag_user=True): + start = f'Congrats u/{redditor.name}, y' if tag_user else 'Y' + return (f'{start}ou have leveled up to "{level_name}"! Your flair has been ' + 'updated accordingly.') + # return (f'Congrats u/{redditor.name}, y + # return (f'Congrats u/{redditor.name}, you have leveled up to "{level_name}"! ' + # 'Your flair has been updated accordingly.') + + +def new_star(redditor, first_star): + num_stars_msg = '' if first_star else 'another ' + return (f'Congrats u/{redditor.name} on getting ' + '{num_stars_msg}{EXCESS_POINTS} points! They are shown as a star ' + 'in your progress bar.') + + +def points_status(redditor, points, level_info): + pointstext = 'points' if points > 1 else 'point' + + if level_info.next: + lines = [ + f'Next level: "{level_info.next.name}"', + f'You have {points} {pointstext}', + f'You need {level_info.next.points} points', + ] + else: + lines = [ + 'MAXIMUM LEVEL ACHIEVED!!!', + f'You have {points} {pointstext}', + ] + + # 2 spaces are appended to each line to force a Markdown line break + lines = [line + ' ' for line in lines] + lines.append(progress_bar(points, level_info)) + + return '\n'.join(lines) + + +def progress_bar(points, level_info): + if points < EXCESS_POINTS: + past, cur, nxt = level_info + allpoints = [lvl.points for lvl in [*past, cur]] + diffs = [a - b for a, b in zip(allpoints, [0] + allpoints)] + bar = [FILLED_SYMBOL * diff for diff in diffs] + + if nxt: + have = points if not cur else points - cur.points + need = nxt.points - points + bar.append((FILLED_SYMBOL * have) + (EMPTY_SYMBOL * need)) + + bar = DIV_SYMBOL.join(bar) + else: + num_excess, num_leftover = divmod(points, EXCESS_POINTS) + bar = [DIV_SYMBOL.join(EXCESS_SYMBOL * num_excess)] + if num_leftover > 0: + bar.append(DIV_SYMBOL) + bar.append(FILLED_SYMBOL * num_leftover) + bar = ''.join(bar) + + return f'[{bar}]' + + +def footer(): + return ('^(This bot is written and maintained by GlipGlorp7 ' + '| Learn more and view the source code on ' + '[Github](https://github.com/cur33/PointsBot))') + # ^(This bot is written and maintained by u/GlipGlorp7 | Learn more and view the source code on [Github](https://github.com/cur33/PointsBot)) + # ^([Learn more]() | [View source code](https://github.com/cur33/PointsBot) | [Contacts mods]()) + + diff --git a/tests/context.py b/tests/context.py new file mode 100644 index 0000000..371019c --- /dev/null +++ b/tests/context.py @@ -0,0 +1,5 @@ +from os.path import abspath, dirname, join +import sys +sys.path.insert(0, abspath(join(dirname(__file__), '..'))) + +import pointsbot diff --git a/tests/test_comment.py b/tests/test_comment.py deleted file mode 100644 index 61bf14f..0000000 --- a/tests/test_comment.py +++ /dev/null @@ -1,15 +0,0 @@ -import comment - -levels = [ - ('Helper', 5), - ('Trusted Helper', 15), - ('Super Helper', 40), -] - -tests = [1, 5, 10, 15, 30, 40, 45] - -for testpoints in tests: - progbar = comment.progress_bar(testpoints, levels) - print(f'Points: {testpoints}') - print(progbar) - print() diff --git a/tests/test_level.py b/tests/test_level.py new file mode 100644 index 0000000..771d53e --- /dev/null +++ b/tests/test_level.py @@ -0,0 +1,40 @@ +import level + +levels = [ + ('Helper', 5), + ('Trusted Helper', 15), + ('Super Helper', 45), +] + + +### Test user_level_info ### + + +pastlvls, curlvl, nextlvl = user_level_info(1, levels) +assert (pastlevels == [] and curlvl is None and nextlvl == levels[0]) + +pastlvls, curlvl, nextlvl = user_level_info(5, levels) +assert (pastlevels == [] and curlvl == levels[0] and nextlvl == levels[1]) + +pastlvls, curlvl, nextlvl = user_level_info(15, levels) +assert (pastlvls == levels[:1] and curlvl == levels[1] and nextlvl == levels[2]) + +pastlvls, curlvl, nextlvl = user_level_info(45, levels) +assert (pastlvls == levels[:2] and curlvl == levels[2] and nextlvl is None) + + +### Test is_max_level ### + + +# TODO I mean, this could be tested exhaustively with positive numbers, even if +# the number of points for the max level is decently large +assert not level.is_max_level(-1, levels) +assert not level.is_max_level(0, levels) +assert not level.is_max_level(4, levels) +assert not level.is_max_level(5, levels) +assert not level.is_max_level(14, levels) +assert not level.is_max_level(15, levels) +assert not level.is_max_level(16, levels) +assert not level.is_max_level(44, levels) +assert level.is_max_level(45, levels) +assert level.is_max_level(46, levels) diff --git a/tests/test_reply.py b/tests/test_reply.py new file mode 100644 index 0000000..8f2967c --- /dev/null +++ b/tests/test_reply.py @@ -0,0 +1,44 @@ +from collections import namedtuple + +from context import pointsbot + +### Data Structures ### + +MockRedditor = namedtuple('MockRedditor', 'id name') + +### Functions ### + + +def leftpad(msg, num_indents=1): + return '\n'.join([('\t' * num_indents + l) for l in msg.split('\n')]) + + +### Tests ### + +levels = [ + pointsbot.level.Level('Novice', 1), + pointsbot.level.Level('Apprentice', 5), + pointsbot.level.Level('Journeyman', 15), + pointsbot.level.Level('Expert', 45), + pointsbot.level.Level('Master I', 100), + pointsbot.level.Level('Master II', 200), + pointsbot.level.Level('Master III', 300), + pointsbot.level.Level('Master IV', 400), + pointsbot.level.Level('Master V', 500), +] + +testredditors = [MockRedditor('1', 'Tim_the_Sorcerer')] +testpoints = [1, 3, 5, 10, 15, 30, 45, 75] + list(range(100, 551, 50)) + +for redditor in testredditors: + for points in testpoints: + level_info = pointsbot.level.user_level_info(points, levels) + body = pointsbot.reply.make(redditor, points, level_info) + print('*' * 80) + print() + print(f'Name: {redditor.name}') + print(f'Points: {points}') + print(f'Body:') + print(leftpad(body, num_indents=1)) + print() +print('*' * 80)