friends 0.34 → 0.35

Sign up to get free protection for your applications and to get access to all the features.
@@ -7,6 +7,7 @@
7
7
  require "set"
8
8
 
9
9
  require "friends/activity"
10
+ require "friends/note"
10
11
  require "friends/friend"
11
12
  require "friends/graph"
12
13
  require "friends/location"
@@ -16,6 +17,7 @@ module Friends
16
17
  class Introvert
17
18
  DEFAULT_FILENAME = "./friends.md".freeze
18
19
  ACTIVITIES_HEADER = "### Activities:".freeze
20
+ NOTES_HEADER = "### Notes:".freeze
19
21
  FRIENDS_HEADER = "### Friends:".freeze
20
22
  LOCATIONS_HEADER = "### Locations:".freeze
21
23
 
@@ -33,7 +35,10 @@ module Friends
33
35
  File.open(@filename, "w") do |file|
34
36
  file.puts(ACTIVITIES_HEADER)
35
37
  stable_sort(@activities).each { |act| file.puts(act.serialize) }
36
- file.puts # Blank line separating activities from friends.
38
+ file.puts # Blank line separating activities from notes.
39
+ file.puts(NOTES_HEADER)
40
+ stable_sort(@notes).each { |note| file.puts(note.serialize) }
41
+ file.puts # Blank line separating notes from friends.
37
42
  file.puts(FRIENDS_HEADER)
38
43
  @friends.sort.each { |friend| file.puts(friend.serialize) }
39
44
  file.puts # Blank line separating friends from locations.
@@ -64,12 +69,20 @@ module Friends
64
69
  # @param serialization [String] the serialized activity
65
70
  # @return [Activity] the added activity
66
71
  def add_activity(serialization:)
67
- activity = Activity.deserialize(serialization)
68
-
69
- activity.highlight_description(introvert: self) if activity.description
70
- @activities.unshift(activity)
72
+ Activity.deserialize(serialization).tap do |activity|
73
+ activity.highlight_description(introvert: self) if activity.description
74
+ @activities.unshift(activity)
75
+ end
76
+ end
71
77
 
72
- activity # Return the added activity.
78
+ # Add a note.
79
+ # @param serialization [String] the serialized note
80
+ # @return [Note] the added note
81
+ def add_note(serialization:)
82
+ Note.deserialize(serialization).tap do |note|
83
+ note.highlight_description(introvert: self) if note.description
84
+ @notes.unshift(note)
85
+ end
73
86
  end
74
87
 
75
88
  # Add a location.
@@ -226,36 +239,16 @@ module Friends
226
239
  list_favorite_things(:location, limit: limit)
227
240
  end
228
241
 
229
- # List all activity details.
230
- # @param limit [Integer] the number of activities to return, or nil for no
231
- # limit
232
- # @param with [Array<String>] the names of friends to filter by, or empty for
233
- # unfiltered
234
- # @param location_name [String] the name of a location to filter by, or
235
- # nil for unfiltered
236
- # @param tagged [Array<String>] the names of tags to filter by, or empty for
237
- # unfiltered
238
- # @param since_date [Date] a date on or after which to find activities, or nil for unfiltered
239
- # @param until_date [Date] a date before or on which to find activities, or nil for unfiltered
240
- # @return [Array] a list of all activity text values
241
- # @raise [ArgumentError] if limit is present but limit < 1
242
- # @raise [FriendsError] if friend, location or tag cannot be found or
243
- # is ambiguous
244
- def list_activities(limit:, with:, location_name:, tagged:, since_date:, until_date:)
245
- raise ArgumentError, "Limit must be positive" if limit && limit < 1
246
-
247
- acts = filtered_activities(
248
- with: with,
249
- location_name: location_name,
250
- tagged: tagged,
251
- since_date: since_date,
252
- until_date: until_date
253
- )
254
-
255
- # If we need to, trim the list.
256
- acts = acts.take(limit) unless limit.nil?
242
+ # See `list_events` for all of the parameters we can pass.
243
+ # @return [Array] a list of all activities' text values
244
+ def list_activities(**args)
245
+ list_events(events: @activities, **args)
246
+ end
257
247
 
258
- acts.map(&:to_s)
248
+ # See `list_events` for all of the parameters we can pass.
249
+ # @return [Array] a list of all notes' text values
250
+ def list_notes(**args)
251
+ list_events(events: @notes, **args)
259
252
  end
260
253
 
261
254
  # List all location names in the friends file.
@@ -264,20 +257,26 @@ module Friends
264
257
  @locations.map(&:name)
265
258
  end
266
259
 
267
- # @param from [String] one of: ["activities", "friends", nil]
268
- # If not nil, limits the tags returned to only those from either
269
- # activities or friends.
260
+ # @param from [Array] containing any of: ["activities", "friends", "notes"]
261
+ # If not empty, limits the tags returned to only those from either
262
+ # activities, notes, or friends.
270
263
  # @return [Array] a sorted list of all tags in activity descriptions
271
264
  def list_tags(from:)
272
265
  output = Set.new
273
266
 
274
- unless from == "friends" # If from is "activities" or nil.
267
+ if from.empty? || from.include?("activities")
275
268
  @activities.each_with_object(output) do |activity, set|
276
269
  set.merge(activity.tags)
277
270
  end
278
271
  end
279
272
 
280
- unless from == "activities" # If from is "friends" or nil.
273
+ if from.empty? || from.include?("notes")
274
+ @notes.each_with_object(output) do |note, set|
275
+ set.merge(note.tags)
276
+ end
277
+ end
278
+
279
+ if from.empty? || from.include?("friends")
281
280
  @friends.each_with_object(output) do |friend, set|
282
281
  set.merge(friend.tags)
283
282
  end
@@ -310,7 +309,8 @@ module Friends
310
309
  # @raise [FriendsError] if friend, location or tag cannot be found or
311
310
  # is ambiguous
312
311
  def graph(with:, location_name:, tagged:, since_date:, until_date:)
313
- filtered_activities_to_graph = filtered_activities(
312
+ filtered_activities_to_graph = filtered_events(
313
+ events: @activities,
314
314
  with: with,
315
315
  location_name: location_name,
316
316
  tagged: tagged,
@@ -324,7 +324,8 @@ module Friends
324
324
  # activities might not include others in the full range (for instance,
325
325
  # if only one filtered activity matches a query, we don't want to only
326
326
  # show unfiltered activities that occurred on that specific day).
327
- all_activities_to_graph = filtered_activities(
327
+ all_activities_to_graph = filtered_events(
328
+ events: @activities,
328
329
  with: [],
329
330
  location_name: nil,
330
331
  tagged: [],
@@ -461,16 +462,66 @@ module Friends
461
462
  @activities.size
462
463
  end
463
464
 
465
+ # @return [Integer] the total number of locations
466
+ def total_locations
467
+ @locations.size
468
+ end
469
+
470
+ # @return [Integer] the total number of notes
471
+ def total_notes
472
+ @notes.size
473
+ end
474
+
475
+ # @return [Integer] the total number of tags
476
+ def total_tags
477
+ list_tags(from: []).size
478
+ end
479
+
464
480
  # @return [Integer] the number of days elapsed between
465
481
  # the first and last activity
466
482
  def elapsed_days
467
- return 0 if @activities.size < 2
468
- sorted_activities = @activities.sort
469
- (sorted_activities.first.date - sorted_activities.last.date).to_i
483
+ events = @activities + @notes
484
+ return 0 if events.size < 2
485
+ sorted_events = events.sort
486
+ (sorted_events.first.date - sorted_events.last.date).to_i
470
487
  end
471
488
 
472
489
  private
473
490
 
491
+ # List all event details.
492
+ # @param events [Array<Event>] the base events to list, either @activities or @notes
493
+ # @param limit [Integer] the number of events to return, or nil for no
494
+ # limit
495
+ # @param with [Array<String>] the names of friends to filter by, or empty for
496
+ # unfiltered
497
+ # @param location_name [String] the name of a location to filter by, or
498
+ # nil for unfiltered
499
+ # @param tagged [Array<String>] the names of tags to filter by, or empty for
500
+ # unfiltered
501
+ # @param since_date [Date] a date on or after which to find events, or nil for unfiltered
502
+ # @param until_date [Date] a date before or on which to find events, or nil for unfiltered
503
+ # @return [Array] a list of all event (activity or note) text values
504
+ # @raise [ArgumentError] if limit is present but limit < 1
505
+ # @raise [FriendsError] if friend, location or tag cannot be found or
506
+ # is ambiguous
507
+ def list_events(events:, limit:, with:, location_name:, tagged:, since_date:, until_date:)
508
+ raise ArgumentError, "Limit must be positive" if limit && limit < 1
509
+
510
+ events = filtered_events(
511
+ events: events,
512
+ with: with,
513
+ location_name: location_name,
514
+ tagged: tagged,
515
+ since_date: since_date,
516
+ until_date: until_date
517
+ )
518
+
519
+ # If we need to, trim the list.
520
+ events = events.take(limit) unless limit.nil?
521
+
522
+ events.map(&:to_s)
523
+ end
524
+
474
525
  # @param arr [Array] an unsorted array
475
526
  # @return [Array] a stably-sorted array
476
527
  def stable_sort(arr)
@@ -478,6 +529,7 @@ module Friends
478
529
  end
479
530
 
480
531
  # Filter activities by friend, location and tag
532
+ # @param events [Array<Event>] the base events to list, either @activities or @notes
481
533
  # @param with [Array<String>] the names of friends to filter by, or empty for
482
534
  # unfiltered
483
535
  # @param location_name [String] the name of a location to filter by, or
@@ -489,35 +541,33 @@ module Friends
489
541
  # @return [Array] an array of activities
490
542
  # @raise [FriendsError] if friend, location or tag cannot be found or
491
543
  # is ambiguous
492
- def filtered_activities(with:, location_name:, tagged:, since_date:, until_date:)
493
- acts = @activities
494
-
544
+ def filtered_events(events:, with:, location_name:, tagged:, since_date:, until_date:)
495
545
  # Filter by friend name if argument is passed.
496
546
  unless with.empty?
497
547
  friends = with.map { |name| thing_with_name_in(:friend, name) }
498
- acts = acts.select do |act|
499
- friends.all? { |friend| act.includes_friend?(friend) }
548
+ events = events.select do |event|
549
+ friends.all? { |friend| event.includes_friend?(friend) }
500
550
  end
501
551
  end
502
552
 
503
553
  # Filter by location name if argument is passed.
504
554
  unless location_name.nil?
505
555
  location = thing_with_name_in(:location, location_name)
506
- acts = acts.select { |act| act.includes_location?(location) }
556
+ events = events.select { |event| event.includes_location?(location) }
507
557
  end
508
558
 
509
559
  # Filter by tag if argument is passed.
510
560
  unless tagged.empty?
511
- acts = acts.select do |act|
512
- tagged.all? { |tag| act.includes_tag?(tag) }
561
+ events = events.select do |event|
562
+ tagged.all? { |tag| event.includes_tag?(tag) }
513
563
  end
514
564
  end
515
565
 
516
566
  # Filter by date if arguments are passed.
517
- acts = acts.select { |act| act.date >= since_date } unless since_date.nil?
518
- acts = acts.select { |act| act.date <= until_date } unless until_date.nil?
567
+ events = events.select { |event| event.date >= since_date } unless since_date.nil?
568
+ events = events.select { |event| event.date <= until_date } unless until_date.nil?
519
569
 
520
- acts
570
+ events
521
571
  end
522
572
 
523
573
  # @param type [Symbol] one of: [:friend, :location]
@@ -612,6 +662,7 @@ module Friends
612
662
  def read_file
613
663
  @friends = []
614
664
  @activities = []
665
+ @notes = []
615
666
  @locations = []
616
667
 
617
668
  return unless File.exist?(@filename)
@@ -650,7 +701,7 @@ module Friends
650
701
  end
651
702
 
652
703
  # If we made it here, we couldn't recognize a header.
653
- bad_line("Couldn't parse line.", line_num)
704
+ bad_line("a valid header", line_num)
654
705
  end
655
706
 
656
707
  # If we made it this far, we're parsing objects in a class.
@@ -670,6 +721,7 @@ module Friends
670
721
  ParsingStage = Struct.new(:id, :klass)
671
722
  PARSING_STAGES = [
672
723
  ParsingStage.new(:activities, Activity),
724
+ ParsingStage.new(:notes, Note),
673
725
  ParsingStage.new(:friends, Friend),
674
726
  ParsingStage.new(:locations, Location)
675
727
  ].freeze
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Note represents a note--a dated record of something
4
+ # about friends or locations that isn't an activity
5
+ # you did. Notes are a good way to remember things big
6
+ # and small, like when your friends move, change jobs,
7
+ # get engaged, or just what food allergies they have
8
+ # for the next time you have them over for dinner.
9
+
10
+ require "friends/event"
11
+
12
+ module Friends
13
+ class Note < Event
14
+ # @return [Regexp] the string of what we expected during deserialization
15
+ def self.deserialization_expectation
16
+ "[YYYY-MM-DD]: [Note]"
17
+ end
18
+ end
19
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Friends
4
- VERSION = "0.34".freeze
4
+ VERSION = "0.35".freeze
5
5
  end
@@ -0,0 +1,410 @@
1
+ def date_parsing_specs(test_stdout: true)
2
+ describe "date parsing" do
3
+ let(:description) { "Test." }
4
+
5
+ describe "when date is in YYYY-MM-DD" do
6
+ let(:date) { "2017-01-01" }
7
+
8
+ it { line_added "- #{date}: #{description}" }
9
+ it { stdout_only "#{capitalized_event} added: \"#{date}: #{description}\"" } if test_stdout
10
+ end
11
+
12
+ describe "when date is in MM-DD-YYYY" do
13
+ let(:date) { "01-02-2017" }
14
+
15
+ it { line_added "- 2017-01-02: #{description}" }
16
+ it { stdout_only "#{capitalized_event} added: \"2017-01-02: #{description}\"" } if test_stdout
17
+ end
18
+
19
+ describe "when date is invalid" do
20
+ let(:date) { "2017-02-30" }
21
+
22
+ it { line_added "- 2017-03-02: #{description}" }
23
+ it { stdout_only "#{capitalized_event} added: \"2017-03-02: #{description}\"" } if test_stdout
24
+ end
25
+
26
+ describe "when date is natural language and in full" do
27
+ let(:date) { "February 23rd, 2017" }
28
+
29
+ it { line_added "- 2017-02-23: #{description}" }
30
+ it { stdout_only "#{capitalized_event} added: \"2017-02-23: #{description}\"" } if test_stdout
31
+ end
32
+
33
+ describe "when date is natural language and only month and day" do
34
+ # We use two days rather than just one to avoid strange behavior around
35
+ # edge cases when the test is being run right around midnight.
36
+ let(:two_days_in_seconds) { 2 * 24 * 60 * 60 }
37
+ let(:raw_date) { Time.now + two_days_in_seconds }
38
+ let(:date) { raw_date.strftime("%B %d") }
39
+ let(:expected_year) { raw_date.strftime("%Y").to_i - 1 }
40
+ let(:expected_date_str) { "#{expected_year}-#{raw_date.strftime('%m-%d')}" }
41
+
42
+ it { line_added "- #{expected_date_str}: #{description}" }
43
+ if test_stdout
44
+ it { stdout_only "#{capitalized_event} added: \"#{expected_date_str}: #{description}\"" }
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ def description_parsing_specs(test_stdout: true)
51
+ describe "description parsing" do
52
+ let(:date) { Date.today.strftime }
53
+
54
+ describe "when description includes a friend's full name (case insensitive)" do
55
+ let(:description) { "Lunch with grace hopper." }
56
+
57
+ it { line_added "- #{date}: Lunch with **Grace Hopper**." }
58
+ if test_stdout
59
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Lunch with Grace Hopper.\"" }
60
+ end
61
+ end
62
+
63
+ describe "when description includes a friend's first name (case insensitive)" do
64
+ let(:description) { "Lunch with grace." }
65
+
66
+ it { line_added "- #{date}: Lunch with **Grace Hopper**." }
67
+ if test_stdout
68
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Lunch with Grace Hopper.\"" }
69
+ end
70
+ end
71
+
72
+ describe "when description has a friend's first name and last initial (case insensitive)" do
73
+ describe "when followed by no period" do
74
+ let(:description) { "Lunch with grace h" }
75
+
76
+ it { line_added "- #{date}: Lunch with **Grace Hopper**" }
77
+ if test_stdout
78
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Lunch with Grace Hopper\"" }
79
+ end
80
+ end
81
+
82
+ describe "when followed by a period at the end of a sentence" do
83
+ let(:description) { "Met grace h. So fun!" }
84
+
85
+ it { line_added "- #{date}: Met **Grace Hopper**. So fun!" }
86
+ if test_stdout
87
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Met Grace Hopper. So fun!\"" }
88
+ end
89
+ end
90
+
91
+ describe "when followed by a period at the end of the description" do
92
+ let(:description) { "Lunch with grace h." }
93
+
94
+ it { line_added "- #{date}: Lunch with **Grace Hopper**." }
95
+ if test_stdout
96
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Lunch with Grace Hopper.\"" }
97
+ end
98
+ end
99
+
100
+ describe "when followed by a period in the middle of a sentence" do
101
+ let(:description) { "Met grace h. at 12." }
102
+
103
+ it { line_added "- #{date}: Met **Grace Hopper** at 12." }
104
+ if test_stdout
105
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Met Grace Hopper at 12.\"" }
106
+ end
107
+ end
108
+ end
109
+
110
+ describe "when description includes a friend's nickname (case insensitive)" do
111
+ let(:description) { "Lunch with the admiral." }
112
+
113
+ it { line_added "- #{date}: Lunch with **Grace Hopper**." }
114
+ if test_stdout
115
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Lunch with Grace Hopper.\"" }
116
+ end
117
+ end
118
+
119
+ describe "when description includes a friend's nickname which contains a name" do
120
+ let(:description) { "Lunch with Amazing Grace." }
121
+
122
+ it { line_added "- #{date}: Lunch with **Grace Hopper**." }
123
+ if test_stdout
124
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Lunch with Grace Hopper.\"" }
125
+ end
126
+ end
127
+
128
+ describe "when description includes a friend's name at the beginning of a word" do
129
+ # Capitalization reduces chance of a false positive.
130
+ let(:description) { "Gracefully strolled." }
131
+
132
+ it { line_added "- #{date}: Gracefully strolled." }
133
+ if test_stdout
134
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Gracefully strolled.\"" }
135
+ end
136
+ end
137
+
138
+ describe "when description includes a friend's name at the end of a word" do
139
+ # Capitalization reduces chance of a false positive.
140
+ let(:description) { "The service was a disGrace." }
141
+
142
+ it { line_added "- #{date}: The service was a disGrace." }
143
+ if test_stdout
144
+ it { stdout_only "#{capitalized_event} added: \"#{date}: The service was a disGrace.\"" }
145
+ end
146
+ end
147
+
148
+ describe "when description includes a friend's name in the middle of a word" do
149
+ # Capitalization reduces chance of a false positive.
150
+ let(:description) { "The service was disGraceful." }
151
+
152
+ it { line_added "- #{date}: The service was disGraceful." }
153
+ if test_stdout
154
+ it { stdout_only "#{capitalized_event} added: \"#{date}: The service was disGraceful.\"" }
155
+ end
156
+ end
157
+
158
+ describe "when a friend's name is escaped with a backslash" do
159
+ # We have to use four backslashes here because of Ruby's backslash escaping; when this
160
+ # goes through all of the layers of this test it emerges on the other side as a single one.
161
+ let(:description) { "Dinner with \\\\Grace Kelly." }
162
+
163
+ it { line_added "- #{date}: Dinner with Grace Kelly." }
164
+ if test_stdout
165
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Dinner with Grace Kelly.\"" }
166
+ end
167
+ end
168
+
169
+ describe "hyphenated name edge cases" do
170
+ describe "when one name precedes another before a hyphen" do
171
+ let(:description) { "Shopped w/ Mary-Kate." }
172
+
173
+ # Make sure "Mary" is a closer friend than "Mary-Kate" so we know our
174
+ # test result isn't due to chance.
175
+ let(:content) do
176
+ <<-FILE
177
+ ### Activities:
178
+ - 2017-01-01: Singing with **Mary Poppins**.
179
+
180
+ ### Notes:
181
+
182
+ ### Friends:
183
+ - Mary Poppins
184
+ - Mary-Kate Olsen
185
+
186
+ ### Locations:
187
+ FILE
188
+ end
189
+
190
+ it { line_added "- #{date}: Shopped w/ **Mary-Kate Olsen**." }
191
+ if test_stdout
192
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Shopped w/ Mary-Kate Olsen.\"" }
193
+ end
194
+ end
195
+
196
+ describe "when one name follows another after a hyphen" do
197
+ let(:description) { "Shopped w/ Mary-Kate." }
198
+
199
+ # Make sure "Kate" is a closer friend than "Mary-Kate" so we know our
200
+ # test result isn't due to chance.
201
+ let(:content) do
202
+ <<-FILE
203
+ ### Activities:
204
+ - 2017-01-01: Improv with **Kate Winslet**.
205
+
206
+ ### Notes:
207
+
208
+ ### Friends:
209
+ - Kate Winslet
210
+ - Mary-Kate Olsen
211
+
212
+ ### Locations:
213
+ FILE
214
+ end
215
+
216
+ it { line_added "- #{date}: Shopped w/ **Mary-Kate Olsen**." }
217
+ if test_stdout
218
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Shopped w/ Mary-Kate Olsen.\"" }
219
+ end
220
+ end
221
+
222
+ describe "when one name is contained within another via hyphens" do
223
+ let(:description) { "Met Mary-Jo-Kate." }
224
+
225
+ # Make sure "Jo" is a closer friend than "Mary-Jo-Kate" so we know our
226
+ # test result isn't due to chance.
227
+ let(:content) do
228
+ <<-FILE
229
+ ### Activities:
230
+ - 2017-01-01: Singing with **Jo Stafford**.
231
+
232
+ ### Notes:
233
+
234
+ ### Friends:
235
+ - Jo Stafford
236
+ - Mary-Jo-Kate Olsen
237
+
238
+ ### Locations:
239
+ FILE
240
+ end
241
+
242
+ it { line_added "- #{date}: Met **Mary-Jo-Kate Olsen**." }
243
+ if test_stdout
244
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Met Mary-Jo-Kate Olsen.\"" }
245
+ end
246
+ end
247
+ end
248
+
249
+ describe "when description has a friend's name with leading asterisks" do
250
+ let(:description) { "Lunch with **Grace Hopper." }
251
+
252
+ it { line_added "- #{date}: Lunch with **Grace Hopper." }
253
+ if test_stdout
254
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Lunch with **Grace Hopper.\"" }
255
+ end
256
+ end
257
+
258
+ describe "when description has a friend's name with trailing asterisks" do
259
+ # Note: We can't guarantee that "Grace Hopper**" doesn't match because the "Grace" isn't
260
+ # surrounded by asterisks.
261
+ let(:description) { "Lunch with Grace**." }
262
+
263
+ it { line_added "- #{date}: Lunch with Grace**." }
264
+ if test_stdout
265
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Lunch with Grace**.\"" }
266
+ end
267
+ end
268
+
269
+ describe "when description has a friend's name multiple times" do
270
+ let(:description) { "Grace! Grace!!!" }
271
+
272
+ it { line_added "- #{date}: **Grace Hopper**! **Grace Hopper**!!!" }
273
+ if test_stdout
274
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Grace Hopper! Grace Hopper!!!\"" }
275
+ end
276
+ end
277
+
278
+ describe "when description has a name with multiple friend matches" do
279
+ describe "when there is useful context from past activities" do
280
+ let(:description) { "Met John + Elizabeth." }
281
+
282
+ # Create a past activity in which Elizabeth Cady Stanton did something
283
+ # with John Cage. Then, create past activities to make Elizabeth II a
284
+ # better friend than Elizabeth Cady Stanton.
285
+ let(:content) do
286
+ <<-FILE
287
+ ### Activities:
288
+ - 2017-01-05: Picnic with **Elizabeth Cady Stanton** and **John Cage**.
289
+ - 2017-01-04: Got lunch with **Elizabeth II**.
290
+ - 2017-01-03: Ice skated with **Elizabeth II**.
291
+
292
+ ### Notes:
293
+
294
+ ### Friends:
295
+ - Elizabeth Cady Stanton
296
+ - Elizabeth II
297
+ - John Cage
298
+
299
+ ### Locations:
300
+ FILE
301
+ end
302
+
303
+ # Elizabeth II is the better friend, but historical activities have
304
+ # had Elizabeth Cady Stanton and John Cage together. Thus, we should
305
+ # interpret "Elizabeth" as Elizabeth Cady Stanton.
306
+ it { line_added "- #{date}: Met **John Cage** + **Elizabeth Cady Stanton**." }
307
+ if test_stdout
308
+ it do
309
+ stdout_only "#{capitalized_event} added: "\
310
+ "\"#{date}: Met John Cage + Elizabeth Cady Stanton.\""
311
+ end
312
+ end
313
+ end
314
+
315
+ describe "when there is no useful context from past activities" do
316
+ let(:description) { "Dinner with John and Elizabeth." }
317
+
318
+ # Create a past activity in which Elizabeth Cady Stanton did something
319
+ # with John Cage. Then, create past activities to make Elizabeth II a
320
+ # better friend than Elizabeth Cady Stanton.
321
+ let(:content) do
322
+ <<-FILE
323
+ ### Activities:
324
+ - 2017-01-03: Ice skated with **Elizabeth II**.
325
+
326
+ ### Notes:
327
+
328
+ ### Friends:
329
+ - Elizabeth Cady Stanton
330
+ - Elizabeth II
331
+ - John Cage
332
+
333
+ ### Locations:
334
+ FILE
335
+ end
336
+
337
+ # Pick the "Elizabeth" with more activities.
338
+ it { line_added "- #{date}: Dinner with **John Cage** and **Elizabeth II**." }
339
+ if test_stdout
340
+ it do
341
+ stdout_only "#{capitalized_event} added: "\
342
+ "\"#{date}: Dinner with John Cage and Elizabeth II.\""
343
+ end
344
+ end
345
+ end
346
+ end
347
+
348
+ describe "when description contains a location name (case insensitive)" do
349
+ let(:description) { "Lunch at a cafe in paris." }
350
+
351
+ it { line_added "- #{date}: Lunch at a cafe in _Paris_." }
352
+ if test_stdout
353
+ it { stdout_only "#{capitalized_event} added: \"#{date}: Lunch at a cafe in Paris.\"" }
354
+ end
355
+ end
356
+
357
+ describe "when description contains both names and locations" do
358
+ let(:description) { "Grace and I went to Atlantis and then Paris for lunch with George." }
359
+
360
+ it do
361
+ line_added "- #{date}: **Grace Hopper** and I went to _Atlantis_ and then _Paris_ for "\
362
+ "lunch with **George Washington Carver**."
363
+ end
364
+ if test_stdout
365
+ it do
366
+ stdout_only "#{capitalized_event} added: \"#{date}: Grace Hopper and I went to "\
367
+ "Atlantis and then Paris for lunch with George Washington Carver.\""
368
+ end
369
+ end
370
+ end
371
+ end
372
+ end
373
+
374
+ def parsing_specs(event:)
375
+ let(:capitalized_event) { event.to_s.capitalize }
376
+
377
+ describe "when given a date and a description in the command" do
378
+ subject { run_cmd("add #{event} #{date}: #{description}") }
379
+
380
+ date_parsing_specs
381
+ description_parsing_specs
382
+ end
383
+
384
+ describe "when given only a date in the command" do
385
+ subject { run_cmd("add #{event} #{date}", stdin_data: description) }
386
+
387
+ # We don't try to test the STDOUT here because our command prompt produces other STDOUT that's
388
+ # hard to test.
389
+ date_parsing_specs(test_stdout: false)
390
+ description_parsing_specs(test_stdout: false)
391
+ end
392
+
393
+ describe "when given only a description in the command" do
394
+ subject { run_cmd("add #{event} #{description}") }
395
+
396
+ # We don't test date parsing since in this case the date is always inferred to be today.
397
+
398
+ description_parsing_specs
399
+ end
400
+
401
+ describe "when given neither a date nor a description in the command" do
402
+ subject { run_cmd("add #{event}", stdin_data: description) }
403
+
404
+ # We don't test date parsing since in this case the date is always inferred to be today.
405
+
406
+ # We don't try to test the STDOUT here because our command prompt produces other STDOUT that's
407
+ # hard to test.
408
+ description_parsing_specs(test_stdout: false)
409
+ end
410
+ end