friends 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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