friends 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,40 @@
1
1
  # Friend represents a friend. You know, a real-life friend!
2
2
 
3
+ require "friends/serializable"
4
+
3
5
  module Friends
4
6
  class Friend
7
+ extend Serializable
8
+
9
+ SERIALIZATION_PREFIX = "- "
10
+
11
+ # @return [Regexp] the regex for capturing groups in deserialization
12
+ def self.deserialization_regex
13
+ /(#{SERIALIZATION_PREFIX})?(?<name>.+)/
14
+ end
15
+
16
+ # @return [Regexp] the string of what we expected during deserialization
17
+ def self.deserialization_expectation
18
+ "[Friend Name]"
19
+ end
20
+
21
+ # @param name [String] the name of the friend
5
22
  def initialize(name:)
6
23
  @name = name
7
24
  end
8
25
 
9
26
  attr_accessor :name
27
+
28
+ # @return [String] the file serialization text for the friend
29
+ def serialize
30
+ "#{SERIALIZATION_PREFIX}#{name}"
31
+ end
32
+
33
+ private
34
+
35
+ # Default sorting for an array of friends is alphabetical.
36
+ def <=>(other)
37
+ name <=> other.name
38
+ end
10
39
  end
11
40
  end
@@ -0,0 +1,4 @@
1
+ module Friends
2
+ class FriendsError < StandardError
3
+ end
4
+ end
@@ -1,29 +1,169 @@
1
- # Introvert is the internal handler for the friends script.
1
+ # Introvert is the internal handler for the friends script. It is designed to be
2
+ # able to be used directly within another Ruby program, without needing to call
3
+ # the command-line script explicitly.
2
4
 
5
+ require "friends/activity"
3
6
  require "friends/friend"
7
+ require "friends/friends_error"
4
8
 
5
9
  module Friends
6
10
  class Introvert
11
+ DEFAULT_FILENAME = "./friends.md"
12
+ ACTIVITIES_HEADER = "### Activities:"
7
13
  FRIENDS_HEADER = "### Friends:"
8
14
 
9
- # @return [String] the name of the friends.md file
10
- def filename
11
- "friends.md"
15
+ # @param filename [String] the name of the friends Markdown file
16
+ def initialize(filename: DEFAULT_FILENAME)
17
+ @filename = filename
18
+ @cleaned_file = false # Switches to true when the file is cleaned.
19
+
20
+ # Read in the input file. It's easier to do this now and optimize later
21
+ # than try to overly be clever about what we read and write.
22
+ read_file(filename: @filename)
12
23
  end
13
24
 
14
- # @return [Array] the list of all Friends
15
- def friends
16
- return @friends if @friends
25
+ attr_reader :filename
26
+ attr_reader :activities
27
+
28
+ # Write out the friends file with cleaned/sorted data.
29
+ def clean
30
+ # Short-circuit if we've already cleaned the file so we don't write it
31
+ # twice.
32
+ return filename if @cleaned_file
33
+
34
+ descriptions = activities.sort.map(&:serialize)
35
+ names = friends.sort.map(&:serialize)
36
+
37
+ # Write out the cleaned file.
38
+ File.open(filename, "w") do |file|
39
+ file.puts(ACTIVITIES_HEADER)
40
+ descriptions.each { |desc| file.puts(desc) }
41
+ file.puts # Blank line separating friends from activities.
42
+ file.puts(FRIENDS_HEADER)
43
+ names.each { |name| file.puts(name) }
44
+ end
45
+
46
+ @cleaned_file = true
47
+
48
+ filename
49
+ end
50
+
51
+ # List all friend names in the friends file.
52
+ # @return [Array] a list of all friend names
53
+ def list_friends
54
+ friends.map(&:name)
55
+ end
17
56
 
18
- read_file(filename: filename, friends_only: true)
57
+ # Add a friend and write out the new friends file.
58
+ # @param name [String] the name of the friend to add
59
+ # @raise [FriendsError] when a friend with that name is already in the file
60
+ # @return [Friend] the added friend
61
+ def add_friend(name:)
62
+ if friend_with_exact_name(name)
63
+ raise FriendsError, "Friend named #{name} already exists"
64
+ end
65
+
66
+ begin
67
+ friend = Friend.deserialize(name)
68
+ rescue Serializable::SerializationError => e
69
+ raise FriendsError, e
70
+ end
71
+
72
+ friends << friend
73
+ clean # Write a cleaned file.
74
+
75
+ friend # Return the added friend.
76
+ end
77
+
78
+ # List all activity details.
79
+ # @param [String] the name of a friend to filter by, or nil for unfiltered
80
+ # @return [Array] a list of all activity text values
81
+ def list_activities(with:)
82
+ acts = activities
83
+
84
+ # Filter by friend name if argument is passed.
85
+ unless with.nil?
86
+ friends = friends_with_name_in(with)
87
+
88
+ case friends.size
89
+ when 1
90
+ # If exactly one friend matches, use that friend to filter.
91
+ acts = acts.select { |a| a.friend_names.include? friends.first.name }
92
+ when 0 then raise FriendsError, "No friend found for \"#{with}\""
93
+ else
94
+ raise FriendsError,
95
+ "More than one friend found for \"#{with}\": "\
96
+ "#{friends.map(&:name).join(', ')}"
97
+ end
98
+ end
99
+
100
+ acts.map(&:display_text)
101
+ end
102
+
103
+ # Add an activity and write out the new friends file.
104
+ # @param serialization [String] the serialized activity
105
+ # @return [Activity] the added activity
106
+ def add_activity(serialization:)
107
+ begin
108
+ activity = Activity.deserialize(serialization)
109
+ rescue Serializable::SerializationError => e
110
+ raise FriendsError, e
111
+ end
112
+
113
+ activity.highlight_friends(friends: friends)
114
+ activities << activity
115
+ clean # Write a cleaned file.
116
+
117
+ activity # Return the added activity.
118
+ end
119
+
120
+ # List your favorite friends.
121
+ # @param num [Integer] the number of favorite friends to return, or nil if
122
+ # unlimited
123
+ # @return [Array] a list of the favorite friends' names and activity
124
+ # counts
125
+ def list_favorites(limit:)
126
+ if !limit.nil? && limit < 1
127
+ raise FriendsError, "Favorites limit must be positive or unlimited"
128
+ end
129
+
130
+ # Construct a hash of friend name to frequency of appearance.
131
+ freq_table = Hash.new { |h, k| h[k] = 0 }
132
+ activities.each do |activity|
133
+ activity.friend_names.each do |friend_name|
134
+ freq_table[friend_name] += 1
135
+ end
136
+ end
137
+
138
+ # Remove names that are not in the friends list.
139
+ freq_table.select! { |name, _| friend_with_exact_name(name) }
140
+
141
+ # Sort the results, with the most favorite friend first.
142
+ results = freq_table.sort_by { |_, count| -count }
143
+
144
+ # If we need to, trim the list.
145
+ results = results.take(limit) unless limit.nil?
146
+
147
+ results.map(&:first)
148
+ end
149
+
150
+ private
151
+
152
+ # Gets the list of friends as read from the file.
153
+ # @return [Array] a list of all friends
154
+ def friends
19
155
  @friends
20
156
  end
21
157
 
22
158
  # Process the friends.md file and store its contents in internal data
23
159
  # structures.
24
160
  # @param filename [String] the name of the friends file
25
- # @param friends_only [Boolean] true if we should only read the friends list
26
- def read_file(filename:, friends_only: false)
161
+ def read_file(filename:)
162
+ @friends = []
163
+ @activities = []
164
+
165
+ return unless File.exist?(filename)
166
+
27
167
  state = :initial
28
168
  line_num = 0
29
169
 
@@ -34,43 +174,57 @@ module Friends
34
174
 
35
175
  case state
36
176
  when :initial
37
- bad_line(FRIENDS_HEADER, line_num) unless line == FRIENDS_HEADER
177
+ bad_line(ACTIVITIES_HEADER, line_num) unless line == ACTIVITIES_HEADER
38
178
 
39
- state = :reading_friends
40
- @friends = []
179
+ state = :reading_activities
41
180
  when :reading_friends
42
- if line == "### Events:"
43
- state = :reading_events
181
+ begin
182
+ @friends << Friend.deserialize(line)
183
+ rescue FriendsError => e
184
+ bad_line(e, line_num)
185
+ end
186
+ when :done_reading_activities
187
+ state = :reading_friends if line == FRIENDS_HEADER
188
+ when :reading_activities
189
+ if line == ""
190
+ state = :done_reading_activities
44
191
  else
45
- match = line.match(/- (?<name>.+)/)
46
- bad_line("- [Friend Name]", line_num) unless match && match[:name]
47
- @friends << Friend.new(name: match[:name])
192
+ begin
193
+ @activities << Activity.deserialize(line)
194
+ rescue FriendsError => e
195
+ bad_line(e, line_num)
196
+ end
48
197
  end
49
198
  end
50
199
  end
51
200
  end
52
201
 
53
- # Write out the friends file with cleaned/sorted data.
54
- def clean
55
- names = friends.sort_by(&:name).map { |friend| "- #{friend.name}" }
56
- File.open(filename, "w") do |file|
57
- file.puts(FRIENDS_HEADER)
58
- names.each { |name| file.puts(name) }
202
+ # @param name [String] the name of the friend to search for
203
+ # @return [Friend] the friend whose name exactly matches the argument
204
+ # @raise [FriendsError] if more than one friend has the given name
205
+ def friend_with_exact_name(name)
206
+ results = friends.select { |friend| friend.name == name }
207
+
208
+ case results.size
209
+ when 0 then nil
210
+ when 1 then results.first
211
+ else raise FriendsError, "More than one friend named #{name}"
59
212
  end
60
213
  end
61
214
 
62
- private
215
+ # @param name [String] the name of the friends to search for
216
+ # @return [Array] a list of all friends that match the given text
217
+ def friends_with_name_in(text)
218
+ regex = Regexp.new(text, Regexp::IGNORECASE)
219
+ friends.select { |friend| friend.name.match(regex) }
220
+ end
63
221
 
64
222
  # Raise an error that a line in the friends file is malformed.
65
223
  # @param expected [String] the expected contents of the line
66
224
  # @param line_num [Integer] the line number
225
+ # @raise [FriendsError] with a constructed message
67
226
  def bad_line(expected, line_num)
68
- error "Expected \"#{expected}\" on line #{line_num}"
69
- end
70
-
71
- # Output the given message and exit the program.
72
- def error(message)
73
- Gossip.error(message)
227
+ raise FriendsError, "Expected \"#{expected}\" on line #{line_num}"
74
228
  end
75
229
  end
76
230
  end
@@ -0,0 +1,32 @@
1
+ # Serializable provides functionality around serialization to the classes that
2
+ # extend it. This includes a class method to deserialize a string and create an
3
+ # instance of the class.
4
+
5
+ module Serializable
6
+ # @param str [String] the serialized object string
7
+ # @return [Object] the object represented by the serialized string
8
+ # Note: this method assumes the calling class provides the following methods:
9
+ # - deserialization_regex
10
+ # a regex for the string which includes named parameters for the
11
+ # different initializer arguments
12
+ # - deserialization_expectation
13
+ # a string for what was expected, if the regex does not match
14
+ def deserialize(str)
15
+ match = str.match(deserialization_regex)
16
+
17
+ unless match
18
+ raise SerializationError,
19
+ "Expected \"#{deserialization_expectation}\""
20
+ end
21
+
22
+ args = match.names.
23
+ map { |name| { name.to_sym => match[name.to_sym] } }.
24
+ reduce(:merge).
25
+ select { |_, v| !v.nil? }
26
+
27
+ new(args)
28
+ end
29
+
30
+ class SerializationError < StandardError
31
+ end
32
+ end
@@ -1,3 +1,3 @@
1
1
  module Friends
2
- VERSION = "0.0.1"
2
+ VERSION = "0.0.2"
3
3
  end
@@ -0,0 +1,172 @@
1
+ require_relative "helper"
2
+
3
+ describe Friends::Activity do
4
+ let(:date) { Date.today }
5
+ let(:date_s) { date.to_s }
6
+ let(:friend1) { Friends::Friend.new(name: "Elizabeth Cady Stanton") }
7
+ let(:friend2) { Friends::Friend.new(name: "John Cage") }
8
+ let(:description) { "Lunch with **#{friend1.name}** and **#{friend2.name}**" }
9
+ let(:activity) do
10
+ Friends::Activity.new(date_s: date_s, description: description)
11
+ end
12
+
13
+ describe ".deserialize" do
14
+ subject { Friends::Activity.deserialize(serialized_str) }
15
+
16
+ describe "when string is well-formed" do
17
+ let(:serialized_str) { "#{date_s}: #{description}" }
18
+
19
+ it "creates an activity with the correct date and description" do
20
+ new_activity = subject
21
+ new_activity.date.must_equal date
22
+ new_activity.description.must_equal description
23
+ end
24
+ end
25
+
26
+ describe "when no date is present" do
27
+ let(:serialized_str) { description }
28
+
29
+ it "defaults to today" do
30
+ today = Date.today
31
+
32
+ # We stub out Date.today to guarantee that it is always the same even
33
+ # when the date changes in the middle of the test's execution.
34
+ Date.stub(:today, today) { subject.date.must_equal today }
35
+ end
36
+ end
37
+
38
+ describe "when string is malformed" do
39
+ let(:serialized_str) { "" }
40
+
41
+ it { proc { subject }.must_raise Serializable::SerializationError }
42
+ end
43
+ end
44
+
45
+ describe "#new" do
46
+ subject { activity }
47
+
48
+ it { subject.date.must_equal date }
49
+ it { subject.description.must_equal description }
50
+ end
51
+
52
+ describe "#display_text" do
53
+ subject { activity.display_text }
54
+
55
+ it do
56
+ subject.
57
+ must_equal "\e[1m#{date_s}\e[0m: "\
58
+ "Lunch with \e[1m#{friend1.name}\e[0m and \e[1m#{friend2.name}\e[0m"
59
+ end
60
+ end
61
+
62
+ describe "#serialize" do
63
+ subject { activity.serialize }
64
+
65
+ it do
66
+ subject.
67
+ must_equal "#{Friends::Activity::SERIALIZATION_PREFIX}#{date_s}: "\
68
+ "#{description}"
69
+ end
70
+ end
71
+
72
+ describe "#highlight_friends" do
73
+ let(:friend1) { Friends::Friend.new(name: "Elizabeth Cady Stanton") }
74
+ let(:friend2) { Friends::Friend.new(name: "John Cage") }
75
+ let(:friends) { [friend1, friend2] }
76
+ let(:description) { "Lunch with #{friend1.name} and #{friend2.name}." }
77
+ subject { activity.highlight_friends(friends: friends) }
78
+
79
+ it "finds all friends" do
80
+ subject
81
+ activity.description.
82
+ must_equal "Lunch with **#{friend1.name}** and **#{friend2.name}**."
83
+ end
84
+
85
+ it "matches friends' first names" do
86
+ activity = Friends::Activity.new(
87
+ date_s: Date.today.to_s,
88
+ description: "Lunch with Elizabeth and John."
89
+ )
90
+ activity.highlight_friends(friends: friends)
91
+ activity.description.
92
+ must_equal "Lunch with **#{friend1.name}** and **#{friend2.name}**."
93
+ end
94
+
95
+ it "matches without case sensitivity" do
96
+ activity = Friends::Activity.new(
97
+ date_s: Date.today.to_s,
98
+ description: "Lunch with elizabeth cady stanton."
99
+ )
100
+ activity.highlight_friends(friends: friends)
101
+ activity.description.
102
+ must_equal "Lunch with **Elizabeth Cady Stanton**."
103
+ end
104
+
105
+ it "ignores when there are multiple matches" do
106
+ friend2.name = "Elizabeth II"
107
+ activity = Friends::Activity.new(
108
+ date_s: Date.today.to_s,
109
+ description: "Dinner with Elizabeth."
110
+ )
111
+ activity.highlight_friends(friends: friends)
112
+ activity.description.must_equal "Dinner with Elizabeth." # No match found.
113
+ end
114
+
115
+ it "does not match with leading asterisks" do
116
+ activity = Friends::Activity.new(
117
+ date_s: Date.today.to_s,
118
+ description: "Dinner with **Elizabeth Cady Stanton."
119
+ )
120
+ activity.highlight_friends(friends: friends)
121
+
122
+ # No match found.
123
+ activity.description.must_equal "Dinner with **Elizabeth Cady Stanton."
124
+ end
125
+
126
+ it "does not match with ending asterisks" do
127
+ activity = Friends::Activity.new(
128
+ date_s: Date.today.to_s,
129
+
130
+ # Note: for now we can't guarantee that "Elizabeth Cady Stanton**" won't
131
+ # match, because the Elizabeth isn't surrounded by asterisks.
132
+ description: "Dinner with Elizabeth**."
133
+ )
134
+ activity.highlight_friends(friends: friends)
135
+
136
+ # No match found.
137
+ activity.description.must_equal "Dinner with Elizabeth**."
138
+ end
139
+ end
140
+
141
+ describe "#friend_names" do
142
+ subject { activity.friend_names }
143
+
144
+ it "returns a list of friend names" do
145
+ names = subject
146
+
147
+ # We don't assert that the output must be in a specific order because we
148
+ # don't care about the order and it is subject to change.
149
+ names.size.must_equal 2
150
+ names.must_include "Elizabeth Cady Stanton"
151
+ names.must_include "John Cage"
152
+ end
153
+
154
+ describe "when a friend is mentioned more than once" do
155
+ let(:description) { "Lunch with **John Cage**. **John Cage** can eat!" }
156
+
157
+ it "removes duplicate names" do
158
+ subject.must_equal ["John Cage"]
159
+ end
160
+ end
161
+ end
162
+
163
+ describe "#<=>" do
164
+ it "sorts by reverse-date" do
165
+ yesterday = (Date.today - 1).to_s
166
+ tomorrow = (Date.today + 1).to_s
167
+ past_act = Friends::Activity.new(date_s: yesterday, description: "Dummy")
168
+ future_act = Friends::Activity.new(date_s: tomorrow, description: "Dummy")
169
+ [past_act, future_act].sort.must_equal [future_act, past_act]
170
+ end
171
+ end
172
+ end