friends 0.28 → 0.29

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