Fixed forward button, minor refactor

This commit is contained in:
asdf 2021-08-08 22:18:30 +10:00
parent 9aed41c8f7
commit 33e8eca10c
4 changed files with 209 additions and 234 deletions

View File

@ -6,7 +6,6 @@ from pathlib import PurePath
from glob import glob
import re
import os
from datetime import datetime
import config
@ -30,7 +29,7 @@ class LinkDataRecord(NamedTuple):
def is_well_formed_line(line: str) -> bool:
"""Checks if current line is valid or not, returns true or false respectively."""
pipe_count = (
4 ## A PROPERLY FORMATED LINE IN linkulator.data HAS EXACTLY FOUR PIPES.
4 # A PROPERLY FORMATED LINE IN linkulator.data HAS EXACTLY FOUR PIPES.
)
return line.count("|") == pipe_count
@ -282,7 +281,8 @@ class LinkData:
"last_modified_timestamp": last_modified_timestamp,
}
)
return sorted(links, key=lambda x: x["last_modified_timestamp"], reverse=True)
links.sort(key=lambda x: x["last_modified_timestamp"], reverse=True)
return links
def get_post(self, post_id) -> dict:
output = {}
@ -299,6 +299,7 @@ class LinkData:
if not output["parent_id"]:
raise ValueError("Sorry, no thread found with that ID.")
# TODO: this should return just the required fields
output["replies"] = sorted(
[record for record in self.link_data if record[3] == output["parent_id"]],
key=lambda x: x[2],

View File

@ -1,44 +1,36 @@
#!/usr/bin/env python3
"""Linkulator"""
## If this script contains bugs, blame cmccabe.
# If this script contains bugs, blame cmccabe and asdf.
import getpass
# import readline
import signal
import subprocess
import sys
import textwrap
from time import time
from urllib.parse import urlparse
from datetime import datetime
from shutil import which
from typing import Tuple
import curses
import curses.textpad as textpad
import getpass
import locale
import signal
from subprocess import call
import sys
import textwrap
from datetime import datetime
from shutil import which
from time import time
from typing import Tuple
from urllib.parse import urlparse
import data
import config
import data
locale.setlocale(locale.LC_ALL, "")
code = locale.getpreferredencoding()
## id (if parent), username, datestamp, parent-id, category, link-url, link-title
# linkdata columns:
# id (if parent), username, datestamp, parent-id, category, link-url, link-title
LinkData = data.LinkData()
link_data: list = LinkData.link_data
# categories: list = LinkData.categories
def print_page_count(pages):
if pages.count > 1:
print("Page {} of {}".format(pages.current, pages.count))
# VIEWS
def print_categories(categories, cols) -> Tuple[str, list[str]]:
def view_categories(categories, cols) -> Tuple[str, list[str]]:
"""Produces categories screen display data. Returns as tuple of header and
content."""
@ -67,18 +59,20 @@ def print_categories(categories, cols) -> Tuple[str, list[str]]:
return thead, content
def print_links(links, cols) -> Tuple[str, list[str]]:
"""Produces links screen display data. Accepts links list and max column
width. Returns a tuple of header and content."""
def view_links(links, category_name, cols) -> Tuple[str, list[str]]:
"""Produces links screen display data. Accepts links list, category name and
max column width. Returns a tuple of header and content."""
max_author_cols = max([len(link["link_author"]) for link in links])
author_cols = max(max_author_cols, 6) # minimum field width is 6
author_cols = max(max_author_cols, 6)
description_cols = cols - 18 - author_cols - 9 - 1
# description_cols calulated as: the terminal width, minus the width of ID
# and Date fields and padding, minus the max name length, minus Resp field
# and padding width, minus the unread mark width
thead = " {:>3s} {:>10s} {:<{author_cols}s} {:<5} {:<s}".format(
thead = " {}\n {:>3s} {:>10s} {:<{author_cols}s} {:<5} {:<s}".format(
category_name.title(),
"ID#",
"Date",
"Author",
@ -113,7 +107,7 @@ def print_links(links, cols) -> Tuple[str, list[str]]:
return thead, content
def print_post(post, cols) -> list[str]:
def view_post(post, cols) -> list[str]:
"""Produces post screen display data. Accepts post id and max column
width. Returns content list."""
@ -151,9 +145,10 @@ def print_post(post, cols) -> list[str]:
return output
def print_search_results(keyword: str, search_results: list):
print("\033c", end="")
"""a view for the search results - prints results to screen"""
def view_search_results(keyword: str, search_results: list):
"""Produces search results display data. Accepts search keyword, search
results and max column width. Returns list of strings."""
# TODO: update to produce list of strings
print(
"\nShowing results for {}\n\n{:>4s} {:<15s}{:<12s}{:<13s}".format(
keyword, "ID#", "DATE", "AUTHOR", "DESC"
@ -168,7 +163,7 @@ def print_search_results(keyword: str, search_results: list):
print("")
## CONTROLS
# CONTROLS
def search():
@ -184,7 +179,7 @@ def search():
print("No results found\n")
return
while True:
print_search_results(keyword, search_results)
view_search_results(keyword, search_results)
option = input(
"Enter a post ID to see its thread, {} to start a new search, {} to go back, or {} to quit: ".format(
style_text("s", False, "underline"),
@ -220,39 +215,44 @@ def open_link_in_browser(url):
url_scheme = urlparse(url).scheme
if url_scheme in ["gopher", "https", "http"]:
subprocess.call([config.USER.browser, url])
call([config.USER.browser, url])
else:
print("Sorry, that url doesn't start with gopher://, http:// or https://")
try_anyway = input(
"Do you want to try it in {} anyway? Y/[N]".format(config.USER.browser)
).lower()
if try_anyway == "y":
subprocess.call([config.USER.browser, url])
call([config.USER.browser, url])
def reply(parent_id):
"""Prompt for reply, validate input, save validated input to disk and update
link_data. Calls view_thread when complete."""
while True:
comment = input("Enter your comment (or leave empty to abort): ")
if comment == "":
input("Reply aborted. Hit [Enter] to continue.")
break
if not is_valid_input(comment):
print(
"Entries consisting of whitespace, or containing pipes, '|', are "
"not valid.Please try again."
)
else:
record = data.LinkDataRecord(
username=getpass.getuser(),
timestamp=str(time()),
parent_id=parent_id,
link_title_or_comment=comment,
)
LinkData.add(record)
input("Reply added. Hit [Enter] to return to thread.")
break
def new_reply(stdscr, post_id):
# TODO
pass
# def reply(parent_id):
# """Prompt for reply, validate input, save validated input to disk and update
# link_data. Calls view_thread when complete."""
# while True:
# comment = input("Enter your comment (or leave empty to abort): ")
# if comment == "":
# input("Reply aborted. Hit [Enter] to continue.")
# break
# if not is_valid_input(comment):
# print(
# "Entries consisting of whitespace, or containing pipes, '|', are "
# "not valid.Please try again."
# )
# else:
# record = data.LinkDataRecord(
# username=getpass.getuser(),
# timestamp=str(time()),
# parent_id=parent_id,
# link_title_or_comment=comment,
# )
# LinkData.add(record)
# input("Reply added. Hit [Enter] to return to thread.")
# break
def is_valid_input(entry: str) -> bool:
@ -339,6 +339,9 @@ def post_link() -> int:
class Output:
"""Menu content that is output to the curses screen, plus a method to
calculate how it is displayed"""
def __init__(self):
self.thead = None
self.content = None
@ -348,10 +351,9 @@ class Output:
self.scrollminy = 0
self.viewslice = None
def Calc(self):
def Calc_Dimensions(self):
if not self.content:
raise ValueError
# TODO: ???
raise ValueError("Can't calculate nonexistant data")
self.length = len(self.content)
if self.thead:
self.miny = 3
@ -361,7 +363,10 @@ class Output:
self.viewslice = slice(self.scrollminy, self.scrollminy + self.maxy)
class Level:
class Menu:
"""Class describing the data contained in each menu level. A level is like a
menu screen in a hierarchy."""
def __init__(self, name):
self.name = name
self.data = None
@ -369,80 +374,83 @@ class Level:
self.selected_key = None
class Menu:
class MenuHierarchy:
"""Class containing all menus, describing their hierarchy and methods to
handle movement between levels"""
def __init__(self):
# main levels: categories -> links -> post
categories = Level("categories")
links = Level("links")
post = Level("post")
self.main_levels = [categories, links, post]
# primary menu hierarchy: categories -> links -> post
categories = Menu("categories")
links = Menu("links")
post = Menu("post")
self.primary_hierarchy = [categories, links, post]
# search levels: search_results -> found_post
search_results = Level("search_results")
found_post = Level("found_post")
self.search_levels = [search_results, found_post]
# search menu hierarchy: search_results -> found_post
search_results = Menu("search_results")
found_post = Menu("found_post")
self.search_hierarchy = [search_results, found_post]
self.is_main_level = True
self.is_viewing_primary_hierarchy = True
self.main_level_index = 0
self.search_level_index = 0
self.current_level = self.main_levels[self.main_level_index]
self.primary_index = 0
self.search_index = 0
self.active = self.primary_hierarchy[self.primary_index]
def back(self):
if self.is_main_level:
if self.main_level_index > 0:
self.main_level_index -= 1
self.current_level = self.main_levels[self.main_level_index]
"""goes back up the menu hierarchy"""
if self.is_viewing_primary_hierarchy:
if self.primary_index > 0:
self.primary_index -= 1
self.active = self.primary_hierarchy[self.primary_index]
else:
if self.search_level_index > 0:
self.search_level_index -= 1
self.current_level = self.search_levels[self.search_level_index]
if self.search_index > 0:
self.search_index -= 1
self.active = self.search_hierarchy[self.search_index]
else:
self.current_level = self.main_levels[self.main_level_index]
self.active = self.primary_hierarchy[self.primary_index]
def forward(self):
if self.is_main_level:
if self.main_level_index < len(self.main_levels) - 1:
self.main_level_index += 1
self.current_level = self.main_levels[self.main_level_index]
"""returns forward through previously visited menu hierarchy"""
if self.is_viewing_primary_hierarchy:
if self.primary_index < len(self.primary_hierarchy) - 1:
if self.primary_hierarchy[self.primary_index + 1].data:
self.primary_index += 1
self.active = self.primary_hierarchy[self.primary_index]
else:
if self.search_level_index < len(self.search_levels) - 1:
self.search_level_index += 1
self.current_level = self.search_levels[self.search_level_index]
else:
self.current_level = self.main_levels[self.main_level_index]
if self.search_index < len(self.search_hierarchy) - 1:
if self.search_hierarchy[self.search_index + 1].data:
self.search_index += 1
self.active = self.search_hierarchy[self.search_index]
def get_data(self, LinkData):
"""fill out current level data based on level settings"""
if self.current_level.name == "categories":
self.current_level.data = LinkData.categories
def set_active_menu_data(self, LinkData):
"""get active menu data based on menu hierarchy settings"""
if self.active.name == "categories":
self.active.data = LinkData.categories
(
self.current_level.output.thead,
self.current_level.output.content,
) = print_categories(self.current_level.data, curses.COLS)
self.active.output.thead,
self.active.output.content,
) = view_categories(self.active.data, curses.COLS)
elif self.current_level.name == "links":
self.current_level.data = LinkData.get_links_by_category_name(
self.current_level.selected_key
elif self.active.name == "links":
self.primary_hierarchy[self.primary_index + 1].data = None
self.active.data = LinkData.get_links_by_category_name(
self.active.selected_key
)
(
self.current_level.output.thead,
self.current_level.output.content,
) = print_links(self.current_level.data, curses.COLS)
self.active.output.thead,
self.active.output.content,
) = view_links(self.active.data, self.active.selected_key, curses.COLS)
elif self.current_level.name == "post":
self.current_level.data = LinkData.get_post(self.current_level.selected_key)
self.current_level.output.content = print_post(
self.current_level.data, curses.COLS
)
elif self.active.name == "post":
self.active.data = LinkData.get_post(self.active.selected_key)
self.active.output.content = view_post(self.active.data, curses.COLS)
elif self.current_level.name == "search_results":
elif self.active.name == "search_results":
pass
elif self.current_level.name == "search_results_thread_details":
elif self.active.name == "search_results_thread_details":
pass
else:
raise ValueError
# TODO: if this fails it's uninitialised or something
raise ValueError("This shouldn't happen?")
class Status:
@ -461,15 +469,18 @@ def term_resize(stdscr):
curses.update_lines_cols()
def new_main_menu(stdscr):
def menu_system(stdscr):
"""main loop of program, prints data and takes action based on user input"""
locale.setlocale(locale.LC_ALL, "")
code = locale.getpreferredencoding()
# curses.use_default_colors()
# curses.curs_set(0)
title = "Linkulator".encode(code)
menu = Menu()
menu.get_data(LinkData)
menu.current_level.output.Calc()
menus = MenuHierarchy()
menus.set_active_menu_data(LinkData)
menus.active.output.Calc_Dimensions()
status = Status()
status.message = ""
@ -487,27 +498,27 @@ def new_main_menu(stdscr):
# stdscr.addstr(1, 4, "All categories".encode(code))
# print header
if menu.current_level.output.thead:
if menus.active.output.thead:
stdscr.addstr(
menu.current_level.output.miny - 1,
menus.active.output.miny - 1,
0,
menu.current_level.output.thead.encode(code),
menus.active.output.thead.encode(code),
)
stdscr.clrtoeol()
# print body
menu.current_level.output.Calc()
menus.active.output.Calc_Dimensions()
count = 0
for i, line in enumerate(
menu.current_level.output.content[menu.current_level.output.viewslice]
menus.active.output.content[menus.active.output.viewslice]
):
# guard just in case we try to print beyond the window because of
# some resize stuff
if i >= curses.LINES:
break
count += count
stdscr.addstr(i + menu.current_level.output.miny, 0, line.encode(code))
stdscr.addstr(i + menus.active.output.miny, 0, line.encode(code))
stdscr.clrtoeol()
# this is meant to clear to the second last line, but...
@ -540,21 +551,22 @@ def new_main_menu(stdscr):
term_resize(stdscr)
elif k in [":", " "]:
iwin = curses.newwin(1, curses.COLS, curses.LINES - 1, 0)
iwin.addch(0,0,":")
iwin.addch(0, 0, ":")
itxt = textpad.Textbox(iwin)
curses.curs_set(1)
itxt.edit()
action = itxt.gather()
# action slice to remove : at the start
# TODO: can this be avoided?
doAction(menu, action[1:], status)
handle_action(menus, action[1:], status)
curses.curs_set(0)
del itxt
del iwin
else:
doAction(menu, k, status)
handle_action(menus, k, status)
def doAction(menu, action, status):
def handle_action(menus, action, status):
int_action = None
try:
int_action = int(action)
@ -564,69 +576,70 @@ def doAction(menu, action, status):
if int_action:
try:
navigate(menu, int_action)
navigate(menus, int_action)
except Exception as e:
status.message = str(e)
else:
try:
handle_command(menu, action)
do_command(menus, action)
except Exception as e:
status.message = str(e)
def navigate(menu, int_action):
def navigate(menus, int_action):
if int_action:
if menu.current_level.name == "categories":
if menus.active.name == "categories":
try:
key = menu.current_level.data[int_action - 1]["name"]
key = menus.active.data[int_action - 1]["name"]
except IndexError:
raise IndexError("Sorry, that category doesn't exist")
return
elif menu.current_level.name == "links":
elif menus.active.name == "links":
try:
key = menu.current_level.data[int_action - 1]["post_id"]
key = menus.active.data[int_action - 1]["post_id"]
except IndexError:
raise IndexError("Sorry, that link doesn't exist")
return
elif menus.active.name == "search_results":
# TODO
pass
else:
# no action because it's not a valid menu
return
menu.forward()
menu.current_level.selected_key = key
menu.get_data(LinkData)
menu.current_level.output.Calc()
menus.primary_index += 1
menus.active = menus.primary_hierarchy[menus.primary_index]
menus.active.selected_key = key
menus.set_active_menu_data(LinkData)
menus.active.output.Calc_Dimensions()
def handle_command(menu, action):
def do_command(menus, action):
"""???"""
if action in ["j", "KEY_DOWN"]:
# TODO: put up/down controls in output class?
if menu.current_level.output.scrollminy <= (
menu.current_level.output.length - menu.current_level.output.maxy
if menus.active.output.scrollminy <= (
menus.active.output.length - menus.active.output.maxy
):
menu.current_level.output.scrollminy += 1
menus.active.output.scrollminy += 1
elif action in ["k", "KEY_UP"]:
if menu.current_level.output.scrollminy > 0:
menu.current_level.output.scrollminy -= 1
if menus.active.output.scrollminy > 0:
menus.active.output.scrollminy -= 1
elif action in ["b", "back"]:
menu.back()
# TODO: add forward as a command?
# it could work like a browser's forward but the command needs a guard to
# not navigate forward unless you have already been forward
# elif action in ["f", "forward"]:
# menu.forward()
menus.back()
elif action in ["f", "forward"]:
menus.forward()
elif action in ["?", "help"]:
pass
# print(HELP_TEXT)
elif action in ["q", "quit", "exit"]:
graceful_exit()
elif action in ["c", "create"]:
#post_id = post_link()
#if post_id >= 0:
# TODO: create new post
# set category_details to the relevant category
# set the selected index to the index of the new post in category_details
# set menu level to thread index
# post_id = post_link()
# if post_id >= 0:
# TODO: create new post
# set category_details to the relevant category
# set the selected index to the index of the new post in category_details
# set menu level to thread index
pass
elif action in ["s", "search"]:
pass
@ -635,13 +648,13 @@ def handle_command(menu, action):
# level.prior_to_search = level.current
# results are returned to a search list, displayed by setting menu level to search
elif action in ["r", "reply"] and menu.current_level.name in [
elif action in ["r", "reply"] and menus.active.name in [
"search_result_thread_details",
"thread_details",
]:
# reply(thread_details["postid"])
pass
elif action in ["o", "open"] and menu.current_level.name in [
# TODO: need stdscr here
new_reply(menus.active.selected_key)
elif action in ["o", "open"] and menus.active.name in [
"search_result_thread_details",
"thread_details",
]:
@ -651,46 +664,12 @@ def handle_command(menu, action):
# is o the right command?
def parse_input() -> str:
output = input("input: ").lower()
return output
"""Gets user input and processes it. Accepts a menu level of categories,
category_details or thread_details. Returns a menu page action or
nothing"""
# def parse_input(level.current: str) -> str:
# if level.current == "categories":
# input_text = (
# "Enter an ID, {}dd a link, {}earch, {}ext or {}prev page, {} or {}uit: ".format(
# style_text("a", True, "underline"),
# style_text("s", True, "underline"),
# style_text("n", True, "underline"),
# style_text("p", True, "underline"),
# style_text("?", True, "underline"),
# style_text("q", True, "underline"),
# )
# )
# elif level.current = "category_details"
# input_text = (
# "Enter an ID, go {}ack, {}dd a link, {}earch, page {}p or {}own".format(
# style_text("b", True, "underline"),
# style_text("a", True, "underline"),
# style_text("s", True, "underline"),
# style_text("n", True, "underline"),
# style_text("?", True, "underline"),
# )
# )
# return input(input_text).lower()
## GENERAL
def style_text(text: str, is_input: bool, *args) -> str:
"""Style input strings as specified using terminal escape sequences. Returns a styled string"""
# TODO: not sure if this function will work with curses
styles = {
"bold": "\033[1m",
"dim": "\033[2m",
@ -712,11 +691,6 @@ def style_text(text: str, is_input: bool, *args) -> str:
return out
def print_banner():
"""prints a banner"""
return " LINKULATOR ".encode(code)
def graceful_exit():
"""Prints a nice message, performs cleanup and then exits"""
config.USER.save()
@ -736,7 +710,7 @@ def main(stdscr):
args = sys.argv[1:]
config.init()
if not args:
new_main_menu(stdscr)
menu_system(stdscr)
elif args[0] in ["-h", "--help", "help"]:
curses.endwin()
print(HELP_TEXT)

View File

@ -25,7 +25,7 @@ class TestDataHelperFunctions(unittest.TestCase):
self.assertEqual(data.wash_line(line["Test"]), line["Result"])
def test_is_well_formed_line(self):
""" tests the data.is_well_formed_line function"""
"""tests the data.is_well_formed_line function"""
teststrings = [
{"Test": "A line of text", "Result": False},
{"Test": "1 Pipe |", "Result": False},

View File

@ -2,16 +2,15 @@
"""Tests for Linkulator views"""
import unittest
from unittest.mock import patch, call
import linkulator
# class TestPrintSearchResults(unittest.TestCase):
# """Tests covering the print_search_results function"""
# TODO: update to support list of strings output
# class TestViewSearchResults(unittest.TestCase):
# """Tests covering the view_search_results function"""
# @patch("builtins.print")
# def test_print_search_results(self, mock_print):
# """tests that the search results are printed correctly"""
# """tests that the search results are produced correctly"""
# test_keyword = "keyword"
# test_search_results = [
# (66, "keyword", "1576461366.5580268", "", "c", "c", "c"),
@ -19,7 +18,7 @@ import linkulator
# (64, "poster7", "1576461368.5580268", "", "c", "keyword", "c"),
# (63, "poster8", "1576461369.5580268", "", "c", "c", "keyword"),
# ]
# test_print_calls = [
# expected_output = [
# call(
# "\nShowing results for keyword\n\n ID# DATE AUTHOR DESC "
# ),
@ -30,18 +29,18 @@ import linkulator
# call(""),
# ]
#
# linkulator.print_search_results(test_keyword, test_search_results)
# linkulator.view_search_results(test_keyword, test_search_results)
#
# self.assertEqual(
# mock_print.call_count, 6
# ) # one count for title, 4 for the items and a blank line for formatting
#
# self.assertListEqual(test_print_calls, mock_print.call_args_list)
# self.assertListEqual(test_view_calls, mock_print.call_args_list)
class TestPrintCategories(unittest.TestCase):
def test_print_categories(self):
"""Test general output of print_categories"""
class TestViewCategories(unittest.TestCase):
def test_view_categories(self):
"""Test general output of view_categories"""
categories = [
{
@ -70,7 +69,7 @@ class TestPrintCategories(unittest.TestCase):
" 3 * long category name that will be truncated because it's a long... (20)",
]
actual_header, actual_content = linkulator.print_categories(categories, cols)
actual_header, actual_content = linkulator.view_categories(categories, cols)
# confirm expected is equal to actual
self.assertEqual(expected_header, actual_header)
@ -91,7 +90,7 @@ class TestPrintCategories(unittest.TestCase):
"",
"There are no posts yet - enter p to post a new link",
]
actual_header, actual_content = linkulator.print_categories(
actual_header, actual_content = linkulator.view_categories(
empty_categories, cols
)
@ -104,9 +103,9 @@ class TestPrintCategories(unittest.TestCase):
self.assertTrue(content_max_cols <= cols)
class TestPrintLinks(unittest.TestCase):
def test_print_links(self):
"""Test general output of print_links"""
class TestViewLinks(unittest.TestCase):
def test_view_links(self):
"""Test general output of view_links"""
links = [
{
@ -130,15 +129,16 @@ class TestPrintLinks(unittest.TestCase):
]
cols = 80
expected_header = (
" ID# Date Author #Repl Description"
)
category_name = "Test Name"
expected_header = " Test Name\n ID# Date Author #Repl Description"
expected_content = [
" 1 2021-07-29 auth 1 [ 0] description 1",
" 2 2021-07-29 author 2 [ 25] a long description for the second post...*",
]
actual_header, actual_content = linkulator.print_links(links, cols)
actual_header, actual_content = linkulator.view_links(
links, category_name, cols
)
# confirm expected is equal to actual
self.assertEqual(expected_header, actual_header)
@ -151,9 +151,9 @@ class TestPrintLinks(unittest.TestCase):
self.assertTrue(content_max_cols <= cols)
class TestPrintPost(unittest.TestCase):
class TestViewPost(unittest.TestCase):
def test_post_without_reply(self):
"""Test print_post where the post has no reply"""
"""Test view_post where the post has no reply"""
post = {
"author": "post author 1",
@ -175,7 +175,7 @@ class TestPrintPost(unittest.TestCase):
"\n No replies yet. Be the first!",
]
actual_content = linkulator.print_post(post, cols)
actual_content = linkulator.view_post(post, cols)
# confirm expected is equal to actual
self.assertListEqual(actual_content, expected_content)
@ -185,7 +185,7 @@ class TestPrintPost(unittest.TestCase):
self.assertTrue(content_max_cols <= cols)
def test_post_with_reply(self):
"""Test print_post where the post has a reply"""
"""Test view_post where the post has a reply"""
post = {
"author": "author2",
@ -228,7 +228,7 @@ class TestPrintPost(unittest.TestCase):
" 1970-01-01 10:16 reply author 2 with a long long long name, a very long name: a reply with a lot of words in it, too many to read, not going to read this",
]
actual_content = linkulator.print_post(post, cols)
actual_content = linkulator.view_post(post, cols)
# confirm expected is equal to actual
self.assertListEqual(actual_content, expected_content)