Refactoring and updating code to make automated testing easier

This commit is contained in:
Eric B. Budd 2020-02-08 11:16:47 -05:00
parent e4dea5c449
commit bb5619ef8f
6 changed files with 152 additions and 38 deletions

View File

@ -379,6 +379,27 @@ This command does not enter Iris' interactive mode.
--- ---
### --debug
This option turns on debug mode. Warnings and errors will be output as the program is used.
Having these messages constantly appear can be distracting or annoying during regular Iris usage, but are useful when tracking down issues.
This option works in both interactive and non-interactive mode.
---
### --test-file/-f
`iris --test-file junk.messages.iris`
This option forces Iris to load the specified message file, instead of scanning the `/home` directory.
This option works in both interactive and non-interactive mode.
---
### --help/-h ### --help/-h
This command displays a complete list of options that Iris recognizes. This command displays a complete list of options that Iris recognizes.

View File

@ -5,16 +5,20 @@
## Documentation: In Progress ## Documentation: In Progress
# For 1.0.8 # For 1.0.8
* Add less-style pagination for long messages
* Add -q/--quiet flag, to create iris message file without user intervention?
* Add integration tests * Add integration tests
* Add ability to run with test iris file * Add ability to run with test iris file
* Continue to make loader more durable against corrupted data files * Continue to make loader more durable against corrupted data files
* Time to start refactoring! * Time to start refactoring!
* Health check CLI flag * Health check CLI flag
* Create local copies of replied-to messages to limit tampering
# Bugs: # Bugs:
* Is `Time.now.utc.iso8601` working as expected? * Is `Time.now.utc.iso8601` working as expected?
* Exclude user's own messages from "unread" count * Exclude user's own messages from "unread" count
* Fix message ordering when editing/deleting multiple messages * Fix message ordering when editing/deleting multiple messages
* Replying implicitly to 24 replied to 6 instead
# Tech debt: # Tech debt:
* Flesh out technical sections * Flesh out technical sections
@ -28,6 +32,10 @@
* Split helptext into separate file? * Split helptext into separate file?
# Features: # Features:
* Add ability to fully manage/read messages from CLI?
* Add local timezone rendering
* Add pagiation/less for long message lists
* Add "Mark unread" option
* Add read-only mode if user doesn't want/can't have message file * Add read-only mode if user doesn't want/can't have message file
* Add user muting (~/.iris.muted) * Add user muting (~/.iris.muted)
* Add message editing * Add message editing

138
iris.rb
View File

@ -1,26 +1,54 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
require 'time'
require 'base64' require 'base64'
require 'digest' require 'digest'
require 'json'
require 'etc' require 'etc'
require 'json'
require 'readline' require 'readline'
require 'time'
# require 'pry' # Only needed for debugging # require 'pry' # Only needed for debugging
class Config class Config
VERSION = '1.0.7' VERSION = '1.0.7'
MESSAGE_FILE = "#{ENV['HOME']}/.iris.messages" MESSAGE_FILE = "#{ENV['HOME']}/.iris.messages"
HISTORY_FILE = "#{ENV['HOME']}/.iris.history" HISTORY_FILE = "#{ENV['HOME']}/.iris.history"
READ_FILE = "#{ENV['HOME']}/.iris.read"
IRIS_SCRIPT = __FILE__ IRIS_SCRIPT = __FILE__
USER = ENV['USER'] || ENV['LOGNAME'] || ENV['USERNAME'] USER = ENV['USER'] || ENV['LOGNAME'] || ENV['USERNAME']
HOSTNAME = `hostname -d`.chomp HOSTNAME = `hostname -d`.chomp
AUTHOR = "#{USER}@#{HOSTNAME}" AUTHOR = "#{USER}@#{HOSTNAME}"
OPTIONS = %w[
--dump
--help
--interactive
--stats
--test-file
--version
-d
-f
-h
-i
-s
-v
]
INTERACTIVE_OPTIONS = %w[-i --interactive]
NONINTERACTIVE_OPTIONS = %w[-d --dump -h --help -v --version -s --stats]
NONFILE_OPTIONS = %w[-h --help -v --version]
def self.find_files def self.find_files
(`ls /home/**/.iris.messages`).split("\n") (`ls /home/**/.iris.messages`).split("\n")
end end
def self.messagefile_filename
$test_corpus_file || Config::MESSAGE_FILE
end
def self.readfile_filename
"#{messagefile_filename}.read"
end
def self.historyfile_filename
"#{messagefile_filename}.history"
end
end end
class String class String
@ -85,9 +113,14 @@ end
class Corpus class Corpus
def self.load def self.load
@@corpus = Config.find_files.map { |filepath| IrisFile.load_messages(filepath) }.flatten.sort_by(&:timestamp) if $test_corpus_file
@@topics = @@corpus.select{ |m| m.parent == nil && m.show_me? } @@corpus = IrisFile.load_messages
else
@@corpus = Config.find_files.map { |filepath| IrisFile.load_messages(filepath) }.flatten.sort_by(&:timestamp)
end
@@my_corpus = IrisFile.load_messages.sort_by(&:timestamp) @@my_corpus = IrisFile.load_messages.sort_by(&:timestamp)
@@topics = @@corpus.select{ |m| m.parent == nil && m.show_me? }
@@my_reads = IrisFile.load_reads @@my_reads = IrisFile.load_reads
@@all_hash_to_index = @@corpus.reduce({}) { |agg, msg| agg[msg.hash] = @@corpus.index(msg); agg } @@all_hash_to_index = @@corpus.reduce({}) { |agg, msg| agg[msg.hash] = @@corpus.index(msg); agg }
@@all_parent_hash_to_index = @@corpus.reduce({}) do |agg, msg| @@all_parent_hash_to_index = @@corpus.reduce({}) do |agg, msg|
@ -187,15 +220,17 @@ class Corpus
end end
class IrisFile class IrisFile
def self.load_messages(filepath = Config::MESSAGE_FILE) def self.load_messages(filepath = nil)
# For logger: "Checking #{filepath}" if filepath.nil?
filepath = Config.messagefile_filename
end
return [] unless File.exists?(filepath) return [] unless File.exists?(filepath)
# For logger: "Found, parsing #{filepath}..."
begin begin
payload = JSON.parse(File.read(filepath)) payload = JSON.parse(File.read(filepath))
rescue JSON::ParserError => e rescue JSON::ParserError => e
if filepath == Config::MESSAGE_FILE if filepath == Config.messagefile_filename
Display.flowerbox( Display.flowerbox(
'Your message file appears to be corrupt.', 'Your message file appears to be corrupt.',
"Could not parse valid JSON from #{filepath}", "Could not parse valid JSON from #{filepath}",
@ -208,7 +243,7 @@ class IrisFile
end end
unless payload.is_a?(Array) unless payload.is_a?(Array)
if filepath == Config::MESSAGE_FILE if filepath == Config.messagefile_filename
Display.flowerbox( Display.flowerbox(
'Your message file appears to be corrupt.', 'Your message file appears to be corrupt.',
"Could not interpret data from #{filepath}", "Could not interpret data from #{filepath}",
@ -232,14 +267,14 @@ class IrisFile
end end
def self.load_reads def self.load_reads
return [] unless File.exists? Config::READ_FILE return [] unless File.exists? Config.readfile_filename
begin begin
read_array = JSON.parse(File.read(Config::READ_FILE)) read_array = JSON.parse(File.read(Config.readfile_filename))
rescue JSON::ParserError => e rescue JSON::ParserError => e
Display.flowerbox( Display.flowerbox(
'Your read file appears to be corrupt.', 'Your read file appears to be corrupt.',
"Could not parse valid JSON from #{Config::READ_FILE}", "Could not parse valid JSON from #{Config.readfile_filename}",
'Please fix or delete this read file to use Iris.') 'Please fix or delete this read file to use Iris.')
exit(1) exit(1)
end end
@ -247,7 +282,7 @@ class IrisFile
unless read_array.is_a?(Array) unless read_array.is_a?(Array)
Display.flowerbox( Display.flowerbox(
'Your read file appears to be corrupt.', 'Your read file appears to be corrupt.',
"Could not interpret data from #{Config::READ_FILE}", "Could not interpret data from #{Config.readfile_filename}",
'(It\'s not a JSON array of message hashes, as far as I can tell)', '(It\'s not a JSON array of message hashes, as far as I can tell)',
'Please fix or delete this read file to use Iris.') 'Please fix or delete this read file to use Iris.')
exit(1) exit(1)
@ -257,23 +292,29 @@ class IrisFile
end end
def self.create_message_file def self.create_message_file
raise 'Should not try to create message file in test mode!' if $test_corpus_file
raise 'Message file exists; refusing to overwrite!' if File.exists?(Config::MESSAGE_FILE) raise 'Message file exists; refusing to overwrite!' if File.exists?(Config::MESSAGE_FILE)
File.umask(0122) File.umask(0122)
File.open(Config::MESSAGE_FILE, 'w') { |f| f.write('[]') } File.open(Config::MESSAGE_FILE, 'w') { |f| f.write('[]') }
end end
def self.create_read_file def self.create_read_file
raise 'Read file exists; refusing to overwrite!' if File.exists?(Config::READ_FILE) return if File.exists?(Config.readfile_filename)
File.umask(0122) File.umask(0122)
File.open(Config::READ_FILE, 'w') { |f| f.write('[]') } File.open(Config.readfile_filename, 'w') { |f| f.write('[]') }
end end
def self.write_corpus(corpus) def self.write_corpus(corpus)
File.write(Config::MESSAGE_FILE, corpus) File.write(Config.messagefile_filename, corpus)
end end
def self.write_read_file(new_read_hashes) def self.write_read_file(new_read_hashes)
File.write(Config::READ_FILE, new_read_hashes) if $test_corpus_file
File.write("#{$test_corpus_file}.read", new_read_hashes)
else
File.write(Config.readfile_filename, new_read_hashes)
end
end end
end end
@ -372,7 +413,7 @@ class Message
stub = message.split("\n").first stub = message.split("\n").first
end end
return stub.colorize if stub.decolorize.length <= length return stub.colorize if stub.decolorize.length <= length
# colorize the stub, then decolorize to strip out any partial tags # Colorize the stub, then decolorize to strip out any partial tags
stub.colorize.slice(0, length - 5 - Display.topic_index_width).decolorize + '...' stub.colorize.slice(0, length - 5 - Display.topic_index_width).decolorize + '...'
end end
@ -577,7 +618,7 @@ class Interface
cmd = CMD_MAP[cmd] || cmd cmd = CMD_MAP[cmd] || cmd
return self.send(cmd.to_sym) if ONE_SHOTS.include?(cmd) && tokens.length == 1 return self.send(cmd.to_sym) if ONE_SHOTS.include?(cmd) && tokens.length == 1
return show_topic(cmd) if cmd =~ /^\d+$/ return show_topic(cmd) if cmd =~ /^\d+$/
# We must have args, let's handle 'em # If we've gotten this far, we must have args. Let's handle 'em.
arg = tokens.last arg = tokens.last
return reply(arg) if cmd == 'reply' return reply(arg) if cmd == 'reply'
return edit(arg) if cmd == 'edit' return edit(arg) if cmd == 'edit'
@ -590,11 +631,17 @@ class Interface
end end
def self.info def self.info
topic_count = Corpus.topics.size
unread_topic_count = Corpus.unread_topics.size
message_count = Corpus.size
unread_message_count = Corpus.unread_messages.size
author_count = Corpus.all.map(&:author).uniq.size
Display.flowerbox( Display.flowerbox(
"Iris #{Config::VERSION}", "Iris #{Config::VERSION}",
"#{Corpus.topics.size} #{'topic'.pluralize(Corpus.topics.size)}, #{Corpus.unread_topics.size} unread.", "#{topic_count} #{'topic'.pluralize(topic_count)}, #{unread_topic_count} unread.",
"#{Corpus.size} #{'message'.pluralize(Corpus.size)}, #{Corpus.unread_messages.size} unread.", "#{message_count} #{'message'.pluralize(message_count)}, #{unread_message_count} unread.",
"#{Corpus.all.map(&:author).uniq.size} authors.", "#{author_count} #{'author'.pluralize(author_count)}.",
box_thickness: 0) box_thickness: 0)
end end
@ -794,7 +841,6 @@ class Interface
end end
def initialize(args) def initialize(args)
Corpus.load
@history_loaded = false @history_loaded = false
@mode = :browsing @mode = :browsing
@ -882,6 +928,7 @@ class CLI
'--stats, -s - Display Iris version and message stats.', '--stats, -s - Display Iris version and message stats.',
'--interactive, -i - Enter interactive mode (default)', '--interactive, -i - Enter interactive mode (default)',
'--dump, -d - Dump entire message corpus out.', '--dump, -d - Dump entire message corpus out.',
'--test-file <filename>, -f <filename> - Use the specified test file for messages.',
'', '',
'If no options are provided, Iris will enter interactive mode.', 'If no options are provided, Iris will enter interactive mode.',
box_character: '') box_character: '')
@ -899,13 +946,11 @@ class CLI
end end
if (args & %w{-s --stats}).any? if (args & %w{-s --stats}).any?
Corpus.load
Interface.info Interface.info
exit(0) exit(0)
end end
if (args & %w{-d --dump}).any? if (args & %w{-d --dump}).any?
Corpus.load
puts Corpus.to_json puts Corpus.to_json
exit(0) exit(0)
end end
@ -917,16 +962,21 @@ end
class Startupper class Startupper
def initialize(args) def initialize(args)
perform_startup_checks perform_file_checks unless Config::NONFILE_OPTIONS.include?(args)
if (args & %w{-i --interactive}).any? || args.empty? load_corpus(args)
is_interactive = (args & Config::NONINTERACTIVE_OPTIONS).none? || (args & Config::INTERACTIVE_OPTIONS).any?
if is_interactive
Interface.start(args) Interface.start(args)
else else
CLI.start(args) CLI.start(args)
end end
end end
def perform_startup_checks def perform_file_checks
raise 'Should not try to perform file checks in test mode!' if $test_corpus_file
unless File.exists?(Config::MESSAGE_FILE) unless File.exists?(Config::MESSAGE_FILE)
Display.say "You don't have a message file at #{Config::MESSAGE_FILE}." Display.say "You don't have a message file at #{Config::MESSAGE_FILE}."
response = Readline.readline 'Would you like me to create it for you? (y/n) ', true response = Readline.readline 'Would you like me to create it for you? (y/n) ', true
@ -939,20 +989,44 @@ class Startupper
end end
end end
IrisFile.create_read_file unless File.exists?(Config::READ_FILE) IrisFile.create_read_file
if File.stat(Config::MESSAGE_FILE).mode != 33188 if File.stat(Config::MESSAGE_FILE).mode != 33188
Display.permissions_error(Config::MESSAGE_FILE, 'message', '-rw-r--r--', '644', "Leaving your file with incorrect permissions could allow unauthorized edits!") Display.permissions_error(Config::MESSAGE_FILE, 'message', '-rw-r--r--', '644', "Leaving your file with incorrect permissions could allow unauthorized edits!")
end end
if File.stat(Config::READ_FILE).mode != 33188 if File.stat(Config.readfile_filename).mode != 33188
Display.permissions_error(Config::READ_FILE, 'read', '-rw-r--r--', '644') Display.permissions_error(Config.readfile_filename, 'read', '-rw-r--r--', '644')
end end
if File.stat(Config::IRIS_SCRIPT).mode != 33261 if File.stat(Config::IRIS_SCRIPT).mode != 33261
Display.permissions_error(Config::IRIS_SCRIPT, 'Iris', '-rwxr-xr-x', '755', 'If this file has the wrong permissions the program may be tampered with!') Display.permissions_error(Config::IRIS_SCRIPT, 'Iris', '-rwxr-xr-x', '755', 'If this file has the wrong permissions the program may be tampered with!')
end end
end end
def load_corpus(args)
$test_corpus_file = nil
if (args & %w{-f --test-file}).any?
filename_idx = (args.index('-f') || args.index('--test-file')) + 1
filename = args[filename_idx]
unless filename
Display.say "Option `--test-file` (`-f`) expects a filename"
exit(1)
end
unless File.exist?(filename)
Display.say "Could not load test file: #{filename}"
exit(1)
end
Display.say "Using Iris with test file: #{filename}"
$test_corpus_file = filename
end
Corpus.load
end
end end
Startupper.new(ARGV) if __FILE__==$0 Startupper.new(ARGV) if __FILE__==$0

4
tests/iris.messages.json Normal file
View File

@ -0,0 +1,4 @@
[
{"hash":"gpY2WW/jGcH+BODgySCwDANJlIM=\n","edit_hash":null,"is_deleted":null,"data":{"author":"calamitous@ctrl-c.club","parent":null,"timestamp":"2020-01-07T21:04:21Z","message":"Test"}},
{"hash":"qubS6AvNXgCJj/4ClFocuJ16SBk=\n","edit_hash":null,"is_deleted":null,"data":{"author":"calamitous@ctrl-c.club","parent":"gpY2WW/jGcH+BODgySCwDANJlIM=\n","timestamp":"2020-01-07T21:32:30Z","message":"Wat"}}
]

View File

@ -0,0 +1 @@
["gpY2WW/jGcH+BODgySCwDANJlIM=\n","qubS6AvNXgCJj/4ClFocuJ16SBk=\n"]

View File

@ -35,7 +35,7 @@ describe Config do
describe '.find_files' do describe '.find_files' do
it 'looks up all the Iris message files on the system' do it 'looks up all the Iris message files on the system' do
# I am so sorry # I am so sorry about this `expects` clause
Config.expects(:`).with('ls /home/**/.iris.messages').returns('') Config.expects(:`).with('ls /home/**/.iris.messages').returns('')
Config.find_files Config.find_files
end end
@ -230,11 +230,17 @@ describe Startupper do
let(:bad_file_stat) { a = mock; a.stubs(:mode).returns(2); a } let(:bad_file_stat) { a = mock; a.stubs(:mode).returns(2); a }
before do before do
Config.send(:remove_const, 'MESSAGE_FILE') Config.stubs(:find_files).returns([])
Config.send(:remove_const, 'READ_FILE') IrisFile.stubs(:load_messages).returns([])
Config.send(:remove_const, 'IRIS_SCRIPT') IrisFile.stubs(:load_reads).returns([])
Config::MESSAGE_FILE = message_file_path
Config::READ_FILE = read_file_path Config.send(:remove_const, 'MESSAGE_FILE') if Config.const_defined? 'MESSAGE_FILE'
Config.send(:remove_const, 'READ_FILE') if Config.const_defined? 'READ_FILE'
Config.send(:remove_const, 'IRIS_SCRIPT') if Config.const_defined? 'IRIS_SCRIPT'
Config::MESSAGE_FILE = message_file_path
Config::READ_FILE = read_file_path
Config.stubs(:messagefile_filename).returns(message_file_path)
Config.stubs(:readfile_filename).returns(read_file_path)
Config::IRIS_SCRIPT = 'doots' Config::IRIS_SCRIPT = 'doots'
File.stubs(:exists?).returns(true) File.stubs(:exists?).returns(true)