friends 0.28 → 0.29

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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -4
  3. data/.ruby-version +1 -1
  4. data/.travis.yml +2 -0
  5. data/CHANGELOG.md +13 -0
  6. data/README.md +35 -5
  7. data/Rakefile +1 -1
  8. data/bin/friends +7 -379
  9. data/friends.gemspec +3 -1
  10. data/lib/friends/activity.rb +4 -4
  11. data/lib/friends/commands/add.rb +64 -0
  12. data/lib/friends/commands/clean.rb +9 -0
  13. data/lib/friends/commands/edit.rb +12 -0
  14. data/lib/friends/commands/graph.rb +56 -0
  15. data/lib/friends/commands/list.rb +143 -0
  16. data/lib/friends/commands/remove.rb +27 -0
  17. data/lib/friends/commands/rename.rb +30 -0
  18. data/lib/friends/commands/set.rb +14 -0
  19. data/lib/friends/commands/stats.rb +11 -0
  20. data/lib/friends/commands/suggest.rb +20 -0
  21. data/lib/friends/commands/update.rb +29 -0
  22. data/lib/friends/graph.rb +7 -7
  23. data/lib/friends/introvert.rb +23 -18
  24. data/lib/friends/version.rb +1 -1
  25. data/test/commands/add/activity_spec.rb +379 -0
  26. data/test/commands/add/friend_spec.rb +30 -0
  27. data/test/commands/add/location_spec.rb +30 -0
  28. data/test/commands/add/nickname_spec.rb +50 -0
  29. data/test/commands/add/tag_spec.rb +65 -0
  30. data/test/commands/clean_spec.rb +39 -0
  31. data/test/commands/graph_spec.rb +147 -0
  32. data/test/commands/help_spec.rb +45 -0
  33. data/test/commands/list/activities_spec.rb +136 -0
  34. data/test/commands/list/favorite/friends_spec.rb +77 -0
  35. data/test/commands/list/favorite/locations_spec.rb +82 -0
  36. data/test/commands/list/friends_spec.rb +76 -0
  37. data/test/commands/list/locations_spec.rb +35 -0
  38. data/test/commands/list/tags_spec.rb +58 -0
  39. data/test/commands/remove/nickname_spec.rb +63 -0
  40. data/test/commands/remove/tag_spec.rb +64 -0
  41. data/test/commands/rename/friend_spec.rb +55 -0
  42. data/test/commands/rename/location_spec.rb +43 -0
  43. data/test/commands/set/location_spec.rb +54 -0
  44. data/test/commands/stats_spec.rb +41 -0
  45. data/test/commands/suggest_spec.rb +86 -0
  46. data/test/commands/update_spec.rb +13 -0
  47. data/test/helper.rb +114 -0
  48. metadata +89 -15
  49. data/test/activity_spec.rb +0 -597
  50. data/test/friend_spec.rb +0 -241
  51. data/test/graph_spec.rb +0 -92
  52. data/test/introvert_spec.rb +0 -969
  53. data/test/location_spec.rb +0 -60
data/lib/friends/graph.rb CHANGED
@@ -5,13 +5,13 @@ module Friends
5
5
  class Graph
6
6
  DATE_FORMAT = "%b %Y"
7
7
 
8
- # @param start_date [Date] the first month of the graph
9
- # @param end_date [Date] the last month of the graph
10
8
  # @param activities [Array<Friends::Activity>] a list of activities to graph
11
- def initialize(start_date:, end_date:, activities:)
12
- self.start_date = start_date
13
- self.end_date = end_date
14
- self.activities = activities
9
+ def initialize(activities:)
10
+ @activities = activities
11
+ unless @activities.empty?
12
+ @start_date = @activities.last.date
13
+ @end_date = @activities.first.date
14
+ end
15
15
  end
16
16
 
17
17
  # Render the graph as a hash in the format:
@@ -45,7 +45,7 @@ module Friends
45
45
  #
46
46
  # @return [Hash{String => Integer}]
47
47
  def empty_graph
48
- Hash[(start_date..end_date).map do |date|
48
+ Hash[(start_date && end_date ? (start_date..end_date) : []).map do |date|
49
49
  [format_date(date), 0]
50
50
  end]
51
51
  end
@@ -231,17 +231,21 @@ module Friends
231
231
  # for unfiltered
232
232
  # @param tagged [String] the name of a tag to filter by (of the form:
233
233
  # "@tag"), or nil for unfiltered
234
+ # @param since_date [Date] a date on or after which to find activities, or nil for unfiltered
235
+ # @param until_date [Date] a date before or on which to find activities, or nil for unfiltered
234
236
  # @return [Array] a list of all activity text values
235
237
  # @raise [ArgumentError] if limit is present but limit < 1
236
238
  # @raise [FriendsError] if friend, location or tag cannot be found or
237
239
  # is ambiguous
238
- def list_activities(limit:, with:, location_name:, tagged:)
240
+ def list_activities(limit:, with:, location_name:, tagged:, since_date:, until_date:)
239
241
  raise ArgumentError, "Limit must be positive" if limit && limit < 1
240
242
 
241
243
  acts = filtered_activities(
242
244
  with: with,
243
245
  location_name: location_name,
244
- tagged: tagged
246
+ tagged: tagged,
247
+ since_date: since_date,
248
+ until_date: until_date
245
249
  )
246
250
 
247
251
  # If we need to, trim the list.
@@ -296,24 +300,21 @@ module Friends
296
300
  # for unfiltered
297
301
  # @param tagged [String] the name of a tag to filter by (of the form:
298
302
  # "@tag"), or nil for unfiltered
303
+ # @param since_date [Date] a date on or after which to find activities, or nil for unfiltered
304
+ # @param until_date [Date] a date before or on which to find activities, or nil for unfiltered
299
305
  # @return [Hash{String => Integer}]
300
306
  # @raise [FriendsError] if friend, location or tag cannot be found or
301
307
  # is ambiguous
302
- def graph(with:, location_name:, tagged:)
303
- # There is no point trying to graph no activities
304
- return {} if @activities.empty?
305
-
308
+ def graph(with:, location_name:, tagged:, since_date:, until_date:)
306
309
  activities_to_graph = filtered_activities(
307
310
  with: with,
308
311
  location_name: location_name,
309
- tagged: tagged
312
+ tagged: tagged,
313
+ since_date: since_date,
314
+ until_date: until_date
310
315
  )
311
316
 
312
- Graph.new(
313
- start_date: @activities.last.date,
314
- end_date: @activities.first.date,
315
- activities: activities_to_graph
316
- ).to_h
317
+ Graph.new(activities: activities_to_graph).to_h
317
318
  end
318
319
 
319
320
  # Suggest friends to do something with.
@@ -456,10 +457,12 @@ module Friends
456
457
  # for unfiltered
457
458
  # @param tagged [String] the name of a tag to filter by, or nil for
458
459
  # unfiltered
460
+ # @param since_date [Date] a date on or after which to find activities, or nil for unfiltered
461
+ # @param until_date [Date] a date before or on which to find activities, or nil for unfiltered
459
462
  # @return [Array] an array of activities
460
463
  # @raise [FriendsError] if friend, location or tag cannot be found or
461
464
  # is ambiguous
462
- def filtered_activities(with:, location_name:, tagged:)
465
+ def filtered_activities(with:, location_name:, tagged:, since_date:, until_date:)
463
466
  acts = @activities
464
467
 
465
468
  # Filter by friend name if argument is passed.
@@ -475,9 +478,11 @@ module Friends
475
478
  end
476
479
 
477
480
  # Filter by tag if argument is passed.
478
- unless tagged.nil?
479
- acts = acts.select { |act| act.includes_tag?(tagged) }
480
- end
481
+ acts = acts.select { |act| act.includes_tag?(tagged) } unless tagged.nil?
482
+
483
+ # Filter by date if arguments are passed.
484
+ acts = acts.select { |act| act.date >= since_date } unless since_date.nil?
485
+ acts = acts.select { |act| act.date <= until_date } unless until_date.nil?
481
486
 
482
487
  acts
483
488
  end
@@ -503,7 +508,7 @@ module Friends
503
508
  results.map.with_index(0) do |thing, index|
504
509
  name = thing.name.ljust(max_str_size)
505
510
  n = thing.n_activities
506
- if index == 0
511
+ if index.zero?
507
512
  label = n == 1 ? " activity" : " activities"
508
513
  end
509
514
  parenthetical = "(#{n}#{label})"
@@ -627,7 +632,7 @@ module Friends
627
632
  # with that exact name, match it.
628
633
  if things.size > 1
629
634
  exact_things = things.select do |thing|
630
- thing.name.casecmp(text) == 0 # We ignore case for an "exact" match.
635
+ thing.name.casecmp(text).zero? # We ignore case for an "exact" match.
631
636
  end
632
637
 
633
638
  things = exact_things if exact_things.size == 1
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Friends
4
- VERSION = "0.28"
4
+ VERSION = "0.29"
5
5
  end
@@ -0,0 +1,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "./test/helper"
4
+
5
+ def date_parsing_specs(test_stdout: true)
6
+ describe "date parsing" do
7
+ let(:description) { "Test." }
8
+
9
+ describe "when date is in YYYY-MM-DD" do
10
+ let(:date) { "2017-01-01" }
11
+
12
+ it { line_added "- #{date}: #{description}" }
13
+ it { stdout_only "Activity added: \"#{date}: #{description}\"" } if test_stdout
14
+ end
15
+
16
+ describe "when date is in MM-DD-YYYY" do
17
+ let(:date) { "01-02-2017" }
18
+
19
+ it { line_added "- 2017-01-02: #{description}" }
20
+ it { stdout_only "Activity added: \"2017-01-02: #{description}\"" } if test_stdout
21
+ end
22
+
23
+ describe "when date is invalid" do
24
+ let(:date) { "2017-02-30" }
25
+
26
+ it { line_added "- 2017-03-02: #{description}" }
27
+ it { stdout_only "Activity added: \"2017-03-02: #{description}\"" } if test_stdout
28
+ end
29
+
30
+ describe "when date is natural language" do
31
+ let(:date) { "February 23rd, 2017" }
32
+
33
+ it { line_added "- 2017-02-23: #{description}" }
34
+ it { stdout_only "Activity added: \"2017-02-23: #{description}\"" } if test_stdout
35
+ end
36
+ end
37
+ end
38
+
39
+ def description_parsing_specs(test_stdout: true)
40
+ describe "description parsing" do
41
+ let(:date) { Date.today.strftime }
42
+
43
+ describe "when description includes a friend's full name (case insensitive)" do
44
+ let(:description) { "Lunch with grace hopper." }
45
+
46
+ it { line_added "- #{date}: Lunch with **Grace Hopper**." }
47
+ it { stdout_only "Activity added: \"#{date}: Lunch with Grace Hopper.\"" } if test_stdout
48
+ end
49
+
50
+ describe "when description includes a friend's first name (case insensitive)" do
51
+ let(:description) { "Lunch with grace." }
52
+
53
+ it { line_added "- #{date}: Lunch with **Grace Hopper**." }
54
+ it { stdout_only "Activity added: \"#{date}: Lunch with Grace Hopper.\"" } if test_stdout
55
+ end
56
+
57
+ describe "when description has a friend's first name and last initial (case insensitive)" do
58
+ describe "when followed by no period" do
59
+ let(:description) { "Lunch with grace h" }
60
+
61
+ it { line_added "- #{date}: Lunch with **Grace Hopper**" }
62
+ it { stdout_only "Activity added: \"#{date}: Lunch with Grace Hopper\"" } if test_stdout
63
+ end
64
+
65
+ describe "when followed by a period at the end of a sentence" do
66
+ let(:description) { "Met grace h. So fun!" }
67
+
68
+ it { line_added "- #{date}: Met **Grace Hopper**. So fun!" }
69
+ it { stdout_only "Activity added: \"#{date}: Met Grace Hopper. So fun!\"" } if test_stdout
70
+ end
71
+
72
+ describe "when followed by a period at the end of the description" do
73
+ let(:description) { "Lunch with grace h." }
74
+
75
+ it { line_added "- #{date}: Lunch with **Grace Hopper**." }
76
+ it { stdout_only "Activity added: \"#{date}: Lunch with Grace Hopper.\"" } if test_stdout
77
+ end
78
+
79
+ describe "when followed by a period in the middle of a sentence" do
80
+ let(:description) { "Met grace h. at 12." }
81
+
82
+ it { line_added "- #{date}: Met **Grace Hopper** at 12." }
83
+ it { stdout_only "Activity added: \"#{date}: Met Grace Hopper at 12.\"" } if test_stdout
84
+ end
85
+ end
86
+
87
+ describe "when description includes a friend's nickname (case insensitive)" do
88
+ let(:description) { "Lunch with the admiral." }
89
+
90
+ it { line_added "- #{date}: Lunch with **Grace Hopper**." }
91
+ it { stdout_only "Activity added: \"#{date}: Lunch with Grace Hopper.\"" } if test_stdout
92
+ end
93
+
94
+ describe "when description includes a friend's nickname which contains a name" do
95
+ let(:description) { "Lunch with Amazing Grace." }
96
+
97
+ it { line_added "- #{date}: Lunch with **Grace Hopper**." }
98
+ it { stdout_only "Activity added: \"#{date}: Lunch with Grace Hopper.\"" } if test_stdout
99
+ end
100
+
101
+ describe "when description includes a friend's name at the beginning of a word" do
102
+ # Capitalization reduces chance of a false positive.
103
+ let(:description) { "Gracefully strolled." }
104
+
105
+ it { line_added "- #{date}: Gracefully strolled." }
106
+ it { stdout_only "Activity added: \"#{date}: Gracefully strolled.\"" } if test_stdout
107
+ end
108
+
109
+ describe "when description includes a friend's name at the end of a word" do
110
+ # Capitalization reduces chance of a false positive.
111
+ let(:description) { "The service was a disGrace." }
112
+
113
+ it { line_added "- #{date}: The service was a disGrace." }
114
+ it { stdout_only "Activity added: \"#{date}: The service was a disGrace.\"" } if test_stdout
115
+ end
116
+
117
+ describe "when description includes a friend's name in the middle of a word" do
118
+ # Capitalization reduces chance of a false positive.
119
+ let(:description) { "The service was disGraceful." }
120
+
121
+ it { line_added "- #{date}: The service was disGraceful." }
122
+ it { stdout_only "Activity added: \"#{date}: The service was disGraceful.\"" } if test_stdout
123
+ end
124
+
125
+ describe "when a friend's name is escaped with a backslash" do
126
+ # We have to use four backslashes here because of Ruby's backslash escaping; when this
127
+ # goes through all of the layers of this test it emerges on the other side as a single one.
128
+ let(:description) { "Dinner with \\\\Grace Kelly." }
129
+
130
+ it { line_added "- #{date}: Dinner with Grace Kelly." }
131
+ it { stdout_only "Activity added: \"#{date}: Dinner with Grace Kelly.\"" } if test_stdout
132
+ end
133
+
134
+ describe "hyphenated name edge cases" do
135
+ describe "when one name precedes another before a hyphen" do
136
+ let(:description) { "Shopped w/ Mary-Kate." }
137
+
138
+ # Make sure "Mary" is a closer friend than "Mary-Kate" so we know our
139
+ # test result isn't due to chance.
140
+ let(:content) do
141
+ <<-FILE
142
+ ### Activities:
143
+ - 2017-01-01: Singing with **Mary Poppins**.
144
+
145
+ ### Friends:
146
+ - Mary Poppins
147
+ - Mary-Kate Olsen
148
+
149
+ ### Locations:
150
+ FILE
151
+ end
152
+
153
+ it { line_added "- #{date}: Shopped w/ **Mary-Kate Olsen**." }
154
+ it { stdout_only "Activity added: \"#{date}: Shopped w/ Mary-Kate Olsen.\"" } if test_stdout
155
+ end
156
+
157
+ describe "when one name follows another after a hyphen" do
158
+ let(:description) { "Shopped w/ Mary-Kate." }
159
+
160
+ # Make sure "Kate" is a closer friend than "Mary-Kate" so we know our
161
+ # test result isn't due to chance.
162
+ let(:content) do
163
+ <<-FILE
164
+ ### Activities:
165
+ - 2017-01-01: Improv with **Kate Winslet**.
166
+
167
+ ### Friends:
168
+ - Kate Winslet
169
+ - Mary-Kate Olsen
170
+
171
+ ### Locations:
172
+ FILE
173
+ end
174
+
175
+ it { line_added "- #{date}: Shopped w/ **Mary-Kate Olsen**." }
176
+ it { stdout_only "Activity added: \"#{date}: Shopped w/ Mary-Kate Olsen.\"" } if test_stdout
177
+ end
178
+
179
+ describe "when one name is contained within another via hyphens" do
180
+ let(:description) { "Met Mary-Jo-Kate." }
181
+
182
+ # Make sure "Jo" is a closer friend than "Mary-Jo-Kate" so we know our
183
+ # test result isn't due to chance.
184
+ let(:content) do
185
+ <<-FILE
186
+ ### Activities:
187
+ - 2017-01-01: Singing with **Jo Stafford**.
188
+
189
+ ### Friends:
190
+ - Jo Stafford
191
+ - Mary-Jo-Kate Olsen
192
+
193
+ ### Locations:
194
+ FILE
195
+ end
196
+
197
+ it { line_added "- #{date}: Met **Mary-Jo-Kate Olsen**." }
198
+ it { stdout_only "Activity added: \"#{date}: Met Mary-Jo-Kate Olsen.\"" } if test_stdout
199
+ end
200
+ end
201
+
202
+ describe "when description has a friend's name with leading asterisks" do
203
+ let(:description) { "Lunch with **Grace Hopper." }
204
+
205
+ it { line_added "- #{date}: Lunch with **Grace Hopper." }
206
+ it { stdout_only "Activity added: \"#{date}: Lunch with **Grace Hopper.\"" } if test_stdout
207
+ end
208
+
209
+ describe "when description has a friend's name with trailing asterisks" do
210
+ # Note: We can't guarantee that "Grace Hopper**" doesn't match because the "Grace" isn't
211
+ # surrounded by asterisks.
212
+ let(:description) { "Lunch with Grace**." }
213
+
214
+ it { line_added "- #{date}: Lunch with Grace**." }
215
+ it { stdout_only "Activity added: \"#{date}: Lunch with Grace**.\"" } if test_stdout
216
+ end
217
+
218
+ describe "when description has a friend's name multiple times" do
219
+ let(:description) { "Grace! Grace!!!" }
220
+
221
+ it { line_added "- #{date}: **Grace Hopper**! **Grace Hopper**!!!" }
222
+ it { stdout_only "Activity added: \"#{date}: Grace Hopper! Grace Hopper!!!\"" } if test_stdout
223
+ end
224
+
225
+ describe "when description has a name with multiple friend matches" do
226
+ describe "when there is useful context from past activities" do
227
+ let(:description) { "Met John + Elizabeth." }
228
+
229
+ # Create a past activity in which Elizabeth Cady Stanton did something
230
+ # with John Cage. Then, create past activities to make Elizabeth II a
231
+ # better friend than Elizabeth Cady Stanton.
232
+ let(:content) do
233
+ <<-FILE
234
+ ### Activities:
235
+ - 2017-01-05: Picnic with **Elizabeth Cady Stanton** and **John Cage**.
236
+ - 2017-01-04: Got lunch with **Elizabeth II**.
237
+ - 2017-01-03: Ice skated with **Elizabeth II**.
238
+
239
+ ### Friends:
240
+ - Elizabeth Cady Stanton
241
+ - Elizabeth II
242
+ - John Cage
243
+
244
+ ### Locations:
245
+ FILE
246
+ end
247
+
248
+ # Elizabeth II is the better friend, but historical activities have
249
+ # had Elizabeth Cady Stanton and John Cage together. Thus, we should
250
+ # interpret "Elizabeth" as Elizabeth Cady Stanton.
251
+ it { line_added "- #{date}: Met **John Cage** + **Elizabeth Cady Stanton**." }
252
+ if test_stdout
253
+ it { stdout_only "Activity added: \"#{date}: Met John Cage + Elizabeth Cady Stanton.\"" }
254
+ end
255
+ end
256
+
257
+ describe "when there is no useful context from past activities" do
258
+ let(:description) { "Dinner with John and Elizabeth." }
259
+
260
+ # Create a past activity in which Elizabeth Cady Stanton did something
261
+ # with John Cage. Then, create past activities to make Elizabeth II a
262
+ # better friend than Elizabeth Cady Stanton.
263
+ let(:content) do
264
+ <<-FILE
265
+ ### Activities:
266
+ - 2017-01-03: Ice skated with **Elizabeth II**.
267
+
268
+ ### Friends:
269
+ - Elizabeth Cady Stanton
270
+ - Elizabeth II
271
+ - John Cage
272
+
273
+ ### Locations:
274
+ FILE
275
+ end
276
+
277
+ # Pick the "Elizabeth" with more activities.
278
+ it { line_added "- #{date}: Dinner with **John Cage** and **Elizabeth II**." }
279
+ if test_stdout
280
+ it { stdout_only "Activity added: \"#{date}: Dinner with John Cage and Elizabeth II.\"" }
281
+ end
282
+ end
283
+ end
284
+
285
+ describe "when description contains a location name (case insensitive)" do
286
+ let(:description) { "Lunch at a cafe in paris." }
287
+
288
+ it { line_added "- #{date}: Lunch at a cafe in _Paris_." }
289
+ it { stdout_only "Activity added: \"#{date}: Lunch at a cafe in Paris.\"" } if test_stdout
290
+ end
291
+
292
+ describe "when description contains both names and locations" do
293
+ let(:description) { "Grace and I went to Atlantis and then Paris for lunch with George." }
294
+
295
+ it do
296
+ line_added "- #{date}: **Grace Hopper** and I went to _Atlantis_ and then _Paris_ for "\
297
+ "lunch with **George Washington Carver**."
298
+ end
299
+ if test_stdout
300
+ it do
301
+ stdout_only "Activity added: \"#{date}: Grace Hopper and I went to Atlantis and then "\
302
+ "Paris for lunch with George Washington Carver.\""
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end
308
+
309
+ clean_describe "add activity" do
310
+ let(:content) { CONTENT }
311
+
312
+ describe "date ordering" do
313
+ let(:content) do
314
+ <<-FILE
315
+ ### Activities:
316
+ - 2017-01-01: Activity 1.
317
+
318
+ ### Friends:
319
+
320
+ ### Locations:
321
+ FILE
322
+ end
323
+
324
+ subject do
325
+ run_cmd("add activity 2017-01-01: Activity 2.")
326
+ run_cmd("add activity 2017-01-01: Activity 3.")
327
+ run_cmd("add activity 2017-01-01: Activity 4.")
328
+ end
329
+
330
+ it "orders dates by insertion time" do
331
+ subject
332
+ File.read(filename).must_equal <<-FILE
333
+ ### Activities:
334
+ - 2017-01-01: Activity 4.
335
+ - 2017-01-01: Activity 3.
336
+ - 2017-01-01: Activity 2.
337
+ - 2017-01-01: Activity 1.
338
+
339
+ ### Friends:
340
+
341
+ ### Locations:
342
+ FILE
343
+ end
344
+ end
345
+
346
+ describe "when given a date and a description in the command" do
347
+ subject { run_cmd("add activity #{date}: #{description}") }
348
+
349
+ date_parsing_specs
350
+ description_parsing_specs
351
+ end
352
+
353
+ describe "when given only a date in the command" do
354
+ subject { run_cmd("add activity #{date}", stdin_data: description) }
355
+
356
+ # We don't try to test the STDOUT here because our command prompt produces other STDOUT that's
357
+ # hard to test.
358
+ date_parsing_specs(test_stdout: false)
359
+ description_parsing_specs(test_stdout: false)
360
+ end
361
+
362
+ describe "when given only a description in the command" do
363
+ subject { run_cmd("add activity #{description}") }
364
+
365
+ # We don't test date parsing since in this case the date is always inferred to be today.
366
+
367
+ description_parsing_specs
368
+ end
369
+
370
+ describe "when given neither a date nor a description in the command" do
371
+ subject { run_cmd("add activity", stdin_data: description) }
372
+
373
+ # We don't test date parsing since in this case the date is always inferred to be today.
374
+
375
+ # We don't try to test the STDOUT here because our command prompt produces other STDOUT that's
376
+ # hard to test.
377
+ description_parsing_specs(test_stdout: false)
378
+ end
379
+ end