friends 0.34 → 0.35

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.
@@ -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