evva 0.8.5 → 0.10.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 573d6c9048cd876148a4cb32de26aaf1bfd1c9a7e269b98f8922a81d9b6d5187
4
- data.tar.gz: 229f516b92262bb14fb67de48b4f0be7462272d049a2b465a3eeb063a348ebf4
3
+ metadata.gz: dec4f42e824897d939df66cc400f64c7d0f8ed0afa61d7c47f90e35a3730593f
4
+ data.tar.gz: 46151f2d8987db8501e73c84dd1d7cddf96fa8364d71495b9e838817bf73b24e
5
5
  SHA512:
6
- metadata.gz: 497f43e9b4d3a96c45cc514743e9c131ef92cb9a8a60e1f4c9314bd8b140ab708ce15fbb269583fc9d316b9499b6a028b70f1ca4127b0365ed783b60dd5e258b
7
- data.tar.gz: 41f177ed9ef2c34ba43fa1d1a3352e66509fc24b313dcc208680bee488b60ecd1cf3272ae4d957db46b744e06cc428bb68f2efb5dfeb499e74b7e65f1d33fd22
6
+ metadata.gz: 0e48b6d338ad0cbea9604afe19ca59b1bcf4842cb28d10681c918a1a3e4532afc9346939c4748bc85cb753f5520e5552c3bc5fa7c45f08050da301249d9f1de9
7
+ data.tar.gz: 0bea87253e07e92fbd7546ee66dd798b6418a7534c1f5655f42990f4d8ec83617bc081c763422ddb1a6c13eec40a1c2d33dc0e4e008c4ce638c17bb732328837
data/.rubocop_todo.yml CHANGED
@@ -109,6 +109,7 @@ Layout/SpaceInsideHashLiteralBraces:
109
109
  Exclude:
110
110
  - 'lib/evva/config.rb'
111
111
  - 'lib/evva/logger.rb'
112
+ - 'spec/evva_spec.rb'
112
113
  - 'spec/lib/evva/config_spec.rb'
113
114
  - 'spec/lib/evva/google_sheet_spec.rb'
114
115
  - 'spec/lib/evva/kotlin_generator_spec.rb'
@@ -253,6 +254,8 @@ Style/HashSyntax:
253
254
  - 'Gemfile'
254
255
  - 'Rakefile'
255
256
  - 'lib/evva.rb'
257
+ - 'spec/evva_spec.rb'
258
+ - 'spec/platform_filter_spec.rb'
256
259
  - 'lib/evva/config.rb'
257
260
  - 'lib/evva/google_sheet.rb'
258
261
  - 'lib/evva/kotlin_generator.rb'
@@ -359,6 +362,7 @@ Style/SymbolProc:
359
362
  # SupportedStylesForMultiline: comma, consistent_comma, no_comma
360
363
  Style/TrailingCommaInArrayLiteral:
361
364
  Exclude:
365
+ - 'spec/evva_spec.rb'
362
366
  - 'spec/lib/evva/google_sheet_spec.rb'
363
367
  - 'spec/lib/evva/kotlin_generator_spec.rb'
364
368
  - 'spec/lib/evva/swift_generator_spec.rb'
@@ -370,6 +374,7 @@ Style/TrailingCommaInArrayLiteral:
370
374
  Style/TrailingCommaInHashLiteral:
371
375
  Exclude:
372
376
  - 'lib/evva/config.rb'
377
+ - 'spec/evva_spec.rb'
373
378
  - 'lib/evva/kotlin_generator.rb'
374
379
  - 'lib/evva/swift_generator.rb'
375
380
  - 'spec/lib/evva/config_spec.rb'
data/Gemfile.lock CHANGED
@@ -1,9 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- evva (0.8.4)
4
+ evva (0.10.0)
5
5
  colorize (~> 1.0)
6
- csv (>= 3.0)
6
+ csv (~> 3.0)
7
7
  safe_yaml (~> 1.0)
8
8
 
9
9
  GEM
data/README.md CHANGED
@@ -36,4 +36,43 @@ Evva automatically generates code for triggering events based on a Google Sheets
36
36
  destinations_file_name: /file/with/destinations
37
37
  special_enum_file_name: /file/with/special/enum/properties/
38
38
  swift_public: false # optional; when true (iOS), generated Swift uses the public access modifier for generated extensions
39
+ filter_by_platforms: # optional; array of platforms to keep (e.g. [ios])
40
+ - ios
39
41
  ```
42
+
43
+ ## The events sheet
44
+
45
+ The events sheet is read by column header, so column order does not matter and
46
+ unknown columns are ignored.
47
+
48
+ | Column | Required | Meaning |
49
+ | --- | --- | --- |
50
+ | `Event Name` | yes | The event name |
51
+ | `Event Properties` | no | Comma separated `name:Type` pairs |
52
+ | `Event Destination` | no | Comma separated destinations |
53
+ | `Platform` | no | Which platforms track the event |
54
+
55
+ ### Platform
56
+
57
+ `Platform` tags an event with the platforms it belongs to. Filtering is driven
58
+ by the `filter_by_platforms` key in `evva_config.yml`: only events matching one
59
+ of the listed platforms (or unrestricted events) are generated. When the key is
60
+ absent, every event is generated.
61
+
62
+ Any value is accepted as a platform name (case insensitive, comma separated).
63
+ An empty cell means the event is unrestricted and will never be filtered out.
64
+
65
+ The header is matched ignoring case and surrounding whitespace, so `platform` and
66
+ `Platform ` are both found.
67
+
68
+ When no `Platform` column is found the run says so, because "every event was
69
+ generated" is otherwise indistinguishable from a header that failed to match:
70
+
71
+ ```
72
+ [INFO] No Platform column in the events sheet, every event will be generated for every platform
73
+ ```
74
+
75
+ Enums whose only referencing events were filtered out are pruned, unless a
76
+ people property also references them.
77
+
78
+ People properties have no `Platform` column and are never filtered.
data/changelog.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.10.0] - 2026-10-13
6
+
7
+ - Support optional people properties in Kotlin: `innerValue` is now `Any?` and optional special properties use `value?.key`
8
+
9
+ ## [0.9.0] - 2026-09-04
10
+
11
+ - Add `Platform` column support in the events sheet and `filter_by_platforms` configuration to filter generated events by platform
12
+
5
13
  ## [0.8.5] - 2026-08-21
6
14
 
7
15
  - Fix `native_type` replacing `Long`/`Boolean` substrings inside custom type names (e.g. `FlyoverLongPressAction` → `FlyoverIntPressAction`)
data/evva.gemspec CHANGED
@@ -19,6 +19,6 @@ Gem::Specification.new do |s|
19
19
  s.required_ruby_version = ">= 3.2.0"
20
20
 
21
21
  s.add_runtime_dependency "colorize", "~> 1.0"
22
- s.add_runtime_dependency "csv", ">= 3.0"
22
+ s.add_runtime_dependency "csv", "~> 3.0"
23
23
  s.add_runtime_dependency "safe_yaml", "~> 1.0"
24
24
  end
@@ -1,17 +1,25 @@
1
1
  module Evva
2
2
  class AnalyticsEvent
3
- attr_reader :event_name, :properties, :destinations
3
+ attr_reader :event_name, :properties, :destinations, :platforms
4
4
 
5
- def initialize(event_name, properties, destinations)
5
+ def initialize(event_name, properties, destinations, platforms = nil)
6
6
  @event_name = event_name
7
7
  @properties = properties
8
8
  @destinations = destinations
9
+ @platforms = platforms
10
+ end
11
+
12
+ def supports_platform?(platform)
13
+ return true if platforms.nil?
14
+
15
+ platforms.include?(platform.to_s.downcase)
9
16
  end
10
17
 
11
18
  def ==(other)
12
19
  event_name == other.event_name &&
13
20
  properties == other.properties &&
14
- destinations == other.destinations
21
+ destinations == other.destinations &&
22
+ platforms == other.platforms
15
23
  end
16
24
  end
17
25
  end
data/lib/evva/config.rb CHANGED
@@ -68,6 +68,10 @@ module Evva
68
68
  @hash[:exclude_destinations] || []
69
69
  end
70
70
 
71
+ def filter_by_platforms
72
+ @hash[:filter_by_platforms]
73
+ end
74
+
71
75
  CONFIG_STRUCT = {
72
76
  type: Hash,
73
77
  elements: {
@@ -83,7 +87,8 @@ module Evva
83
87
  destinations_file_name: { type: String },
84
88
  package_name: { type: String },
85
89
  swift_public: { type: Object, optional: true },
86
- exclude_destinations: { type: Array, optional: true }
90
+ exclude_destinations: { type: Array, optional: true },
91
+ filter_by_platforms: { type: Array, optional: true }
87
92
  }
88
93
  }.freeze
89
94
 
@@ -6,6 +6,7 @@ module Evva
6
6
  EVENT_NAME = "Event Name"
7
7
  EVENT_PROPERTIES = "Event Properties"
8
8
  EVENT_DESTINATION = "Event Destination"
9
+ EVENT_PLATFORM = "Platform"
9
10
 
10
11
  PROPERTY_NAME = "Property Name"
11
12
  PROPERTY_TYPE = "Property Type"
@@ -26,11 +27,17 @@ module Evva
26
27
  get_csv(@events_url)
27
28
  end
28
29
 
29
- @events ||= @events_csv.map do |row|
30
- event_name = row[EVENT_NAME]
31
- properties = hash_parser(row[EVENT_PROPERTIES])
32
- destinations = row[EVENT_DESTINATION]&.split(",")
33
- Evva::AnalyticsEvent.new(event_name, properties, destinations || [])
30
+ @events ||= begin
31
+ platform_header = header_matching(@events_csv, EVENT_PLATFORM)
32
+ Logger.info("No #{EVENT_PLATFORM} column in the events sheet, every event will be generated for every platform") if platform_header.nil?
33
+
34
+ @events_csv.map do |row|
35
+ event_name = row[EVENT_NAME]
36
+ properties = hash_parser(row[EVENT_PROPERTIES])
37
+ destinations = row[EVENT_DESTINATION]&.split(",")
38
+ platforms = platform_parser(platform_header && row[platform_header])
39
+ Evva::AnalyticsEvent.new(event_name, properties, destinations || [], platforms)
40
+ end
34
41
  end
35
42
  end
36
43
 
@@ -96,6 +103,21 @@ module Evva
96
103
  response.body
97
104
  end
98
105
 
106
+ # Matched ignoring case and surrounding whitespace, because a header of
107
+ # "platform" or "Platform " means what it plainly means.
108
+ def header_matching(csv, name)
109
+ csv.headers.compact.find { |header| header.to_s.strip.casecmp?(name) }
110
+ end
111
+
112
+ # nil (no Platform column or empty cell) means unrestricted (never filtered out).
113
+ # Concrete platform tokens are stored lowercased and deduplicated.
114
+ def platform_parser(platform_list)
115
+ tokens = platform_list.to_s.split(",").map(&:strip).reject(&:empty?)
116
+ return nil if tokens.empty?
117
+
118
+ tokens.map(&:downcase).uniq
119
+ end
120
+
99
121
  def hash_parser(property_array)
100
122
  h = {}
101
123
  unless property_array.nil? || property_array.empty?
@@ -84,11 +84,21 @@ module Evva
84
84
 
85
85
  properties = people_bundle.map do |property|
86
86
  type = native_type(property.type)
87
+ value_fetcher = "value"
88
+
89
+ if special_property?(type)
90
+ if type.end_with?("?")
91
+ # optional value, we need ? to access a parameter
92
+ value_fetcher += "?"
93
+ end
94
+ value_fetcher += ".key"
95
+ end
96
+
87
97
  {
88
98
  class_name: camelize(property.property_name),
89
99
  property_name: constantize(property.property_name),
90
100
  type: type,
91
- is_special_property: special_property?(property.type),
101
+ value_fetcher: value_fetcher,
92
102
  destinations: property.destinations.map { |p| constantize(p) },
93
103
  }
94
104
  end
@@ -1,6 +1,6 @@
1
1
  sealed class <%= class_name %>(
2
2
  property: <%= enums_class_name %>,
3
- val innerValue: Any,
3
+ val innerValue: Any?,
4
4
  val destinations: Array<<%= destinations_class_name %>> = emptyArray(),
5
5
  ) {
6
6
  val name = property.key
@@ -10,7 +10,7 @@ sealed class <%= class_name %>(
10
10
  val value: <%= property[:type] %>,
11
11
  ) : <%= class_name %>(
12
12
  property = <%= enums_class_name %>.<%= property[:property_name] %>,
13
- innerValue = value<% if property[:is_special_property] %>.key<% end %>,
13
+ innerValue = <%= property[:value_fetcher] %>,
14
14
  <%- if property[:destinations].count > 0 -%>
15
15
  destinations = arrayOf(
16
16
  <%- property[:destinations].each_with_index do |d, index| -%>
data/lib/evva/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  module Evva
2
- VERSION = "0.8.5".freeze
3
- VERSION_UPDATED_AT = "2026-08-21".freeze
2
+ VERSION = "0.10.0".freeze
3
+ VERSION_UPDATED_AT = "2026-10-13".freeze
4
4
  end
data/lib/evva.rb CHANGED
@@ -26,6 +26,7 @@ module Evva
26
26
  config = Evva::Config.new(hash: YAML.safe_load(config_file))
27
27
  bundle = analytics_data(config: config.data_source)
28
28
  filter_destinations!(bundle, config.exclude_destinations)
29
+ filter_platforms!(bundle, config.filter_by_platforms)
29
30
  case config.type.downcase
30
31
  when "android"
31
32
  generator = Evva::KotlinGenerator.new(config.package_name)
@@ -102,6 +103,40 @@ module Evva
102
103
  bundle[:people].each { |p| p.destinations.reject! { |d| excluded.include?(d) } }
103
104
  end
104
105
 
106
+ def filter_platforms!(bundle, platforms)
107
+ return if platforms.nil? || platforms.empty?
108
+
109
+ normalized = platforms.map { |p| p.to_s.downcase }
110
+
111
+ kept, filtered = bundle[:events].partition { |event|
112
+ normalized.any? { |p| event.supports_platform?(p) }
113
+ }
114
+ return if filtered.empty?
115
+
116
+ bundle[:events] = kept
117
+ Logger.info("Filtered #{filtered.size} events (#{filtered.map(&:event_name).join(', ')})")
118
+
119
+ # Only what the dropped events were keeping alive. An enum that had no
120
+ # reference before this run keeps its place on purpose: pruning those would
121
+ # change the output of sheets that have no Platform column at all.
122
+ orphaned = types_referenced_by(filtered) - types_referenced_by(kept, bundle[:people])
123
+ pruned, remaining = bundle[:enums].partition { |enum| orphaned.include?(enum.enum_name) }
124
+ return if pruned.empty?
125
+
126
+ bundle[:enums] = remaining
127
+ Logger.info("Pruned #{pruned.size} enums (#{pruned.map(&:enum_name).join(', ')})")
128
+ end
129
+
130
+ # Every property type these events and people properties name, with the
131
+ # optional marker stripped. The caller compares against real enum names, so
132
+ # this never has to decide whether a type is an enum. People properties are
133
+ # never platform filtered but still hold references, so an enum kept alive only
134
+ # by one must survive.
135
+ def types_referenced_by(events, people = [])
136
+ types = events.flat_map { |event| event.properties.values } + people.map(&:type)
137
+ types.map { |type| type.to_s.chomp("?") }
138
+ end
139
+
105
140
  def write_to_file(path, data)
106
141
  file_reader = Evva::FileReader.new
107
142
  if file = file_reader.open_file(path, "w", false)
data/spec/evva_spec.rb CHANGED
@@ -57,6 +57,189 @@ describe Evva do
57
57
  end
58
58
  end
59
59
 
60
+ describe ".filter_platforms!" do
61
+ def event(name, platforms, properties = {})
62
+ Evva::AnalyticsEvent.new(name, properties, ["firebase"], platforms)
63
+ end
64
+
65
+ let(:unrestricted) { event("unrestricted", nil) }
66
+ let(:ios_only) { event("ios_only", ["ios"], { from_screen: "IosOnlyEnum" }) }
67
+ let(:android_only) { event("android_only", ["android"], { from_screen: "AndroidOnlyEnum" }) }
68
+
69
+ let(:events) { [unrestricted, ios_only, android_only] }
70
+
71
+ let(:enums) do
72
+ [
73
+ Evva::AnalyticsEnum.new("IosOnlyEnum", ["a"]),
74
+ Evva::AnalyticsEnum.new("AndroidOnlyEnum", ["b"]),
75
+ Evva::AnalyticsEnum.new("NeverReferenced", ["c"]),
76
+ ]
77
+ end
78
+
79
+ let(:people) { [Evva::AnalyticsProperty.new("total_friends", "Int", [])] }
80
+
81
+ let(:bundle) do
82
+ { destinations: ["firebase"], events: events, people: people, enums: enums }
83
+ end
84
+
85
+ def enum_names
86
+ bundle[:enums].map(&:enum_name)
87
+ end
88
+
89
+ def event_names
90
+ bundle[:events].map(&:event_name)
91
+ end
92
+
93
+ context "when filtering for iOS" do
94
+ before { Evva.filter_platforms!(bundle, ["ios"]) }
95
+
96
+ it "keeps unrestricted events" do
97
+ expect(event_names).to include("unrestricted")
98
+ end
99
+
100
+ it "keeps iOS events" do
101
+ expect(event_names).to include("ios_only")
102
+ end
103
+
104
+ it "drops Android events" do
105
+ expect(event_names).not_to include("android_only")
106
+ end
107
+ end
108
+
109
+ context "when filtering for Android" do
110
+ before { Evva.filter_platforms!(bundle, ["android"]) }
111
+
112
+ it "keeps unrestricted events" do
113
+ expect(event_names).to include("unrestricted")
114
+ end
115
+
116
+ it "keeps Android events" do
117
+ expect(event_names).to include("android_only")
118
+ end
119
+
120
+ it "drops iOS events" do
121
+ expect(event_names).not_to include("ios_only")
122
+ end
123
+ end
124
+
125
+ context "when filtering for multiple platforms" do
126
+ before { Evva.filter_platforms!(bundle, ["ios", "android"]) }
127
+
128
+ it "keeps events matching any of them" do
129
+ expect(event_names).to eq(["unrestricted", "ios_only", "android_only"])
130
+ end
131
+ end
132
+
133
+ context "when filter_by_platforms is nil" do
134
+ before { Evva.filter_platforms!(bundle, nil) }
135
+
136
+ it "does not filter anything" do
137
+ expect(event_names).to eq(["unrestricted", "ios_only", "android_only"])
138
+ end
139
+ end
140
+
141
+ context "when filter_by_platforms is empty" do
142
+ before { Evva.filter_platforms!(bundle, []) }
143
+
144
+ it "does not filter anything" do
145
+ expect(event_names).to eq(["unrestricted", "ios_only", "android_only"])
146
+ end
147
+ end
148
+
149
+ it "accounts for every event it was given" do
150
+ events_in = bundle[:events].size
151
+ Evva.filter_platforms!(bundle, ["ios"])
152
+ events_out = bundle[:events].size
153
+ events_filtered = events.count { |e| !e.supports_platform?("ios") }
154
+
155
+ expect(events_in).to eq(events_out + events_filtered)
156
+ end
157
+
158
+ context "when no event is filtered out" do
159
+ let(:events) { [unrestricted] }
160
+
161
+ it "leaves the events untouched" do
162
+ expect { Evva.filter_platforms!(bundle, ["ios"]) }.not_to change { bundle[:events] }
163
+ end
164
+
165
+ it "leaves the enums untouched, orphans included" do
166
+ expect { Evva.filter_platforms!(bundle, ["ios"]) }.not_to change { enum_names }
167
+ end
168
+
169
+ it "logs nothing" do
170
+ expect {
171
+ Evva.filter_platforms!(bundle, ["ios"])
172
+ }.to not_change { Evva::Logger.summary[:info] }
173
+ end
174
+ end
175
+
176
+ describe "enum pruning" do
177
+ it "prunes an enum whose only event was filtered out" do
178
+ Evva.filter_platforms!(bundle, ["ios"])
179
+
180
+ expect(enum_names).not_to include("AndroidOnlyEnum")
181
+ end
182
+
183
+ it "keeps an enum still referenced by a surviving event" do
184
+ Evva.filter_platforms!(bundle, ["ios"])
185
+
186
+ expect(enum_names).to include("IosOnlyEnum")
187
+ end
188
+
189
+ it "keeps an enum that was already unreferenced before filtering" do
190
+ Evva.filter_platforms!(bundle, ["ios"])
191
+
192
+ expect(enum_names).to include("NeverReferenced")
193
+ end
194
+
195
+ context "when a filtered event's enum is also referenced by a people property" do
196
+ let(:people) do
197
+ [Evva::AnalyticsProperty.new("wearable_platform", "AndroidOnlyEnum", [])]
198
+ end
199
+
200
+ it "keeps the enum" do
201
+ Evva.filter_platforms!(bundle, ["ios"])
202
+
203
+ expect(enum_names).to include("AndroidOnlyEnum")
204
+ end
205
+ end
206
+
207
+ context "when the reference is optional" do
208
+ let(:android_only) { event("android_only", ["android"], { from_screen: "AndroidOnlyEnum?" }) }
209
+ let(:ios_only) { event("ios_only", ["ios"], { from_screen: "IosOnlyEnum?" }) }
210
+
211
+ it "prunes the filtered platform's enum" do
212
+ Evva.filter_platforms!(bundle, ["ios"])
213
+
214
+ expect(enum_names).not_to include("AndroidOnlyEnum")
215
+ end
216
+
217
+ it "keeps the surviving platform's enum" do
218
+ Evva.filter_platforms!(bundle, ["ios"])
219
+
220
+ expect(enum_names).to include("IosOnlyEnum")
221
+ end
222
+ end
223
+ end
224
+
225
+ describe "logging" do
226
+ subject(:messages) do
227
+ logged = []
228
+ allow(Evva::Logger).to receive(:info) { |msg| logged << msg }
229
+ Evva.filter_platforms!(bundle, ["ios"])
230
+ logged
231
+ end
232
+
233
+ it "names the filtered events" do
234
+ expect(messages).to include("Filtered 1 events (android_only)")
235
+ end
236
+
237
+ it "names the pruned enums" do
238
+ expect(messages).to include("Pruned 1 enums (AndroidOnlyEnum)")
239
+ end
240
+ end
241
+ end
242
+
60
243
  context "when generic.yml does not exist locally" do
61
244
  let(:error) { "Could not open yml file" }
62
245
  before do
@@ -0,0 +1,6 @@
1
+ Enum Name,Possible Values
2
+ PageViewSourceScreen,"course_discovery,synced_courses,nearby,deal"
3
+ IosOnlyEnum,"a,b"
4
+ AndroidOnlyEnum,"c,d"
5
+ SharedEnum,"e,f"
6
+ WearableAppPlatform,"wear_os,watch_os"
@@ -0,0 +1,7 @@
1
+ Event Name,Platform,Event Properties,Added in,Event Destination,Description
2
+ cp_page_view,,"course_id:Long,course_name:String",1.0,"firebase,custom destination",Empty cell means every platform
3
+ ios_only_event,iOS,from_screen:IosOnlyEnum,1.1,firebase,Only tracked on iOS
4
+ android_only_event,Android,from_screen:AndroidOnlyEnum,1.1,firebase,Only tracked on Android
5
+ multi_platform_event,"iOS, Android",source:SharedEnum,1.2,firebase,Both platforms spelled out as a list
6
+ mixed_case_event,IOS,,1.2,firebase,Casing is normalised
7
+ wear_sync_event,iOS,platform:WearableAppPlatform,1.3,firebase,Enum also referenced by a people property
@@ -65,6 +65,24 @@ describe Evva::Config do
65
65
  end
66
66
  end
67
67
 
68
+ describe "#filter_by_platforms" do
69
+ context "when not set" do
70
+ its(:filter_by_platforms) { should be_nil }
71
+ end
72
+
73
+ context "when set to an array" do
74
+ before { hash[:filter_by_platforms] = ["ios"] }
75
+
76
+ its(:filter_by_platforms) { should eq(["ios"]) }
77
+ end
78
+
79
+ context "when not an array" do
80
+ before { hash[:filter_by_platforms] = "ios" }
81
+
82
+ it { expect { config }.to raise_error(ArgumentError, /Expected Array, got String/) }
83
+ end
84
+ end
85
+
68
86
  describe "#swift_public?" do
69
87
  context "when swift_public is true" do
70
88
  before { hash[:swift_public] = true }
@@ -23,14 +23,120 @@ describe Evva::GoogleSheet do
23
23
 
24
24
  it "returns an array with the corresponding events" do
25
25
  expected = [
26
- Evva::AnalyticsEvent.new("cp_page_view", { course_id: "Long", course_name: "String" }, ["firebase", "custom destination"]),
27
- Evva::AnalyticsEvent.new("nav_feed_tap", {}, []),
28
- Evva::AnalyticsEvent.new("cp_view_scorecard", { course_id: "Long", course_name: "String" }, ["custom destination"]),
29
- Evva::AnalyticsEvent.new("side_game_delete", { fromScreen: "SideGameFromScreen", round_group_creation_token: "String" }, ["firebase"]),
26
+ Evva::AnalyticsEvent.new("cp_page_view", { course_id: "Long", course_name: "String" }, ["firebase", "custom destination"], nil),
27
+ Evva::AnalyticsEvent.new("nav_feed_tap", {}, [], nil),
28
+ Evva::AnalyticsEvent.new("cp_view_scorecard", { course_id: "Long", course_name: "String" }, ["custom destination"], nil),
29
+ Evva::AnalyticsEvent.new("side_game_delete", { fromScreen: "SideGameFromScreen", round_group_creation_token: "String" }, ["firebase"], nil),
30
30
  ]
31
31
  expect(events).to eq(expected)
32
32
  end
33
33
 
34
+ context "when the sheet has no Platform column" do
35
+ it "leaves platforms as nil (unrestricted)" do
36
+ expect(events.map(&:platforms)).to all(be_nil)
37
+ end
38
+ end
39
+
40
+ context "when the sheet has a Platform column" do
41
+ let(:events_file) { File.read("spec/fixtures/sample_public_events_with_platform.csv") }
42
+
43
+ def platforms_for(event_name)
44
+ events.find { |event| event.event_name == event_name }.platforms
45
+ end
46
+
47
+ it do
48
+ expect { events }.not_to raise_error
49
+ end
50
+
51
+ it "reads the column by header name, whatever its position" do
52
+ expect(events.map(&:event_name)).to eq(%w[
53
+ cp_page_view
54
+ ios_only_event
55
+ android_only_event
56
+ multi_platform_event
57
+ mixed_case_event
58
+ wear_sync_event
59
+ ])
60
+ end
61
+
62
+ it "treats an empty cell as unrestricted" do
63
+ expect(platforms_for("cp_page_view")).to be_nil
64
+ end
65
+
66
+ it "reads a single platform" do
67
+ expect(platforms_for("ios_only_event")).to eq(["ios"])
68
+ expect(platforms_for("android_only_event")).to eq(["android"])
69
+ end
70
+
71
+ it "reads a comma separated list of platforms" do
72
+ expect(platforms_for("multi_platform_event")).to eq(["ios", "android"])
73
+ end
74
+
75
+ it "normalises casing" do
76
+ expect(platforms_for("mixed_case_event")).to eq(["ios"])
77
+ end
78
+ end
79
+
80
+ ["platform", "Platform ", " Platform", "PLATFORM", "pLaTfOrM"].each do |header|
81
+ context "when the Platform header is written as #{header.inspect}" do
82
+ let(:events_file) { %(Event Name,"#{header}"\nsome_event,Android\n) }
83
+
84
+ it "still finds the column" do
85
+ expect(events.first.platforms).to eq(["android"])
86
+ end
87
+ end
88
+ end
89
+
90
+ context "when there is no Platform column" do
91
+ let(:events_file) { "Event Name,Event Destination\nsome_event,firebase\n" }
92
+
93
+ it "says so, so that a mistyped header is not mistaken for a filter that ran" do
94
+ logged = []
95
+ allow(Evva::Logger).to receive(:info) { |msg| logged << msg }
96
+
97
+ events
98
+
99
+ expect(logged).to include(/No Platform column/)
100
+ end
101
+ end
102
+
103
+ context "when the Platform column is present" do
104
+ let(:events_file) { "Event Name,Platform\nsome_event,iOS\n" }
105
+
106
+ it "does not report a missing column" do
107
+ logged = []
108
+ allow(Evva::Logger).to receive(:info) { |msg| logged << msg }
109
+
110
+ events
111
+
112
+ expect(logged).not_to include(/No Platform column/)
113
+ end
114
+ end
115
+
116
+ context "when a platform cell is written in another casing" do
117
+ let(:events_file) { "Event Name,Platform\nsome_event, AnDrOiD \n" }
118
+
119
+ it "normalises it" do
120
+ expect(events.first.platforms).to eq(["android"])
121
+ end
122
+ end
123
+
124
+ context "when a platform cell repeats a platform" do
125
+ let(:events_file) { "Event Name,Platform\nsome_event,\"ios,iOS\"\n" }
126
+
127
+ it "does not duplicate it" do
128
+ expect(events.first.platforms).to eq(["ios"])
129
+ end
130
+ end
131
+
132
+ context "when a platform cell has an unknown token" do
133
+ let(:events_file) { "Event Name,Platform\nsome_event,Windows\n" }
134
+
135
+ it "stores it as-is (lowercased)" do
136
+ expect(events.first.platforms).to eq(["windows"])
137
+ end
138
+ end
139
+
34
140
  context "when given an inexistent sheet" do
35
141
  before { stub_request(:get, events_sheet).to_return(status: 400, body: "Not Found", headers: {}) }
36
142
 
@@ -156,9 +156,10 @@ Kotlin
156
156
  describe "#people_properties" do
157
157
  subject { generator.people_properties(people_bundle, "AnalyticsProperty", "AnalyticsProperties", "AnalyticsDestinations") }
158
158
  let(:people_bundle) { [
159
- Evva::AnalyticsProperty.new("rounds_with_wear", "String", []),
159
+ Evva::AnalyticsProperty.new("rounds_with_wear", "String?", []),
160
160
  Evva::AnalyticsProperty.new("last_active_at", "Date", []),
161
- Evva::AnalyticsProperty.new("wear_platform", "WearableAppPlatform", ["firebase", "custom destination"]),
161
+ Evva::AnalyticsProperty.new("upgrade_source", "PremiumFrom", []),
162
+ Evva::AnalyticsProperty.new("wear_platform", "WearableAppPlatform?", ["firebase", "custom destination"]),
162
163
  ] }
163
164
  let(:expected) {
164
165
  <<-Kotlin
@@ -170,13 +171,13 @@ package com.hole19golf.hole19.analytics
170
171
 
171
172
  sealed class AnalyticsProperty(
172
173
  property: AnalyticsProperties,
173
- val innerValue: Any,
174
+ val innerValue: Any?,
174
175
  val destinations: Array<AnalyticsDestinations> = emptyArray(),
175
176
  ) {
176
177
  val name = property.key
177
178
 
178
179
  data class RoundsWithWear(
179
- val value: String,
180
+ val value: String?,
180
181
  ) : AnalyticsProperty(
181
182
  property = AnalyticsProperties.ROUNDS_WITH_WEAR,
182
183
  innerValue = value,
@@ -189,11 +190,18 @@ sealed class AnalyticsProperty(
189
190
  innerValue = value,
190
191
  )
191
192
 
193
+ data class UpgradeSource(
194
+ val value: PremiumFrom,
195
+ ) : AnalyticsProperty(
196
+ property = AnalyticsProperties.UPGRADE_SOURCE,
197
+ innerValue = value.key,
198
+ )
199
+
192
200
  data class WearPlatform(
193
- val value: WearableAppPlatform,
201
+ val value: WearableAppPlatform?,
194
202
  ) : AnalyticsProperty(
195
203
  property = AnalyticsProperties.WEAR_PLATFORM,
196
- innerValue = value.key,
204
+ innerValue = value?.key,
197
205
  destinations = arrayOf(
198
206
  AnalyticsDestinations.FIREBASE,
199
207
  AnalyticsDestinations.CUSTOM_DESTINATION,
@@ -0,0 +1,147 @@
1
+ describe "filtering by platform end to end" do
2
+ let(:events_url) { "https://wtvr1" }
3
+ let(:people_url) { "https://wtvr2" }
4
+ let(:enum_url) { "https://wtvr3" }
5
+
6
+ let(:events_fixture) { "sample_public_events_with_platform.csv" }
7
+ let(:enums_fixture) { "sample_public_enums_with_platform.csv" }
8
+
9
+ before do
10
+ stub_request(:get, events_url).to_return(status: 200, body: fixture(events_fixture))
11
+ stub_request(:get, people_url).to_return(status: 200, body: fixture("sample_public_people_properties.csv"))
12
+ stub_request(:get, enum_url).to_return(status: 200, body: fixture(enums_fixture))
13
+ end
14
+
15
+ def fixture(name)
16
+ File.read("spec/fixtures/#{name}")
17
+ end
18
+
19
+ def unfiltered_bundle
20
+ source = {
21
+ type: "google_sheet",
22
+ events_url: events_url,
23
+ people_properties_url: people_url,
24
+ enum_classes_url: enum_url
25
+ }
26
+ Evva.analytics_data(config: source)
27
+ end
28
+
29
+ def bundle_for(platforms)
30
+ bundle = unfiltered_bundle
31
+ Evva.filter_platforms!(bundle, platforms)
32
+ bundle
33
+ end
34
+
35
+ def logged_while
36
+ logged = []
37
+ allow(Evva::Logger).to receive(:info) { |msg| logged << msg }
38
+ yield
39
+ logged
40
+ end
41
+
42
+ describe "for iOS" do
43
+ let(:bundle) { bundle_for(["ios"]) }
44
+
45
+ it "keeps the iOS and unrestricted events" do
46
+ expect(bundle[:events].map(&:event_name)).to eq(%w[
47
+ cp_page_view
48
+ ios_only_event
49
+ multi_platform_event
50
+ mixed_case_event
51
+ wear_sync_event
52
+ ])
53
+ end
54
+
55
+ it "accounts for every event it was given" do
56
+ filtered = logged_while { bundle }.grep(/^Filtered/).first.to_s[/\((.*)\)/, 1].to_s.split(", ")
57
+
58
+ expect(bundle[:events].size + filtered.size).to eq(unfiltered_bundle[:events].size)
59
+ end
60
+
61
+ it "prunes the Android only enum and keeps the rest" do
62
+ expect(bundle[:enums].map(&:enum_name)).to eq(%w[
63
+ PageViewSourceScreen
64
+ IosOnlyEnum
65
+ SharedEnum
66
+ WearableAppPlatform
67
+ ])
68
+ end
69
+ end
70
+
71
+ describe "for Android" do
72
+ let(:bundle) { bundle_for(["android"]) }
73
+
74
+ it "keeps the Android and unrestricted events" do
75
+ expect(bundle[:events].map(&:event_name)).to eq(%w[
76
+ cp_page_view
77
+ android_only_event
78
+ multi_platform_event
79
+ ])
80
+ end
81
+
82
+ it "prunes the iOS only enum" do
83
+ expect(bundle[:enums].map(&:enum_name)).not_to include("IosOnlyEnum")
84
+ end
85
+
86
+ # wear_sync_event is iOS only and gets filtered here, but the
87
+ # wearable_platform people property still references the enum.
88
+ it "keeps an enum held only by a people property" do
89
+ expect(bundle[:enums].map(&:enum_name)).to include("WearableAppPlatform")
90
+ end
91
+
92
+ it "keeps the enum no event ever referenced" do
93
+ expect(bundle[:enums].map(&:enum_name)).to include("PageViewSourceScreen")
94
+ end
95
+ end
96
+
97
+ describe "for multiple platforms" do
98
+ let(:bundle) { bundle_for(["ios", "android"]) }
99
+
100
+ it "keeps events matching any of the platforms" do
101
+ expect(bundle[:events].map(&:event_name)).to eq(%w[
102
+ cp_page_view
103
+ ios_only_event
104
+ android_only_event
105
+ multi_platform_event
106
+ mixed_case_event
107
+ wear_sync_event
108
+ ])
109
+ end
110
+ end
111
+
112
+ # What protects the sheets that predate the column.
113
+ # The generators are pure functions of the bundle and are untouched here, so a
114
+ # bundle the filter left alone generates exactly what it generated before.
115
+ describe "a sheet with no Platform column" do
116
+ let(:events_fixture) { "sample_public_events.csv" }
117
+ let(:enums_fixture) { "sample_public_enums.csv" }
118
+
119
+ [["ios"], ["android"]].each do |platforms|
120
+ it "leaves the #{platforms} bundle untouched" do
121
+ before_filtering = unfiltered_bundle
122
+ after_filtering = bundle_for(platforms)
123
+
124
+ expect(after_filtering[:events].map(&:event_name)).to eq(before_filtering[:events].map(&:event_name))
125
+ expect(after_filtering[:enums].map(&:enum_name)).to eq(before_filtering[:enums].map(&:enum_name))
126
+ expect(after_filtering[:people].map(&:property_name)).to eq(before_filtering[:people].map(&:property_name))
127
+ end
128
+ end
129
+
130
+ it "keeps enums that no event references" do
131
+ expect(bundle_for(["ios"])[:enums].map(&:enum_name)).to eq(%w[PageViewSourceScreen PremiumClickBuy])
132
+ end
133
+
134
+ it "reports no filtering" do
135
+ expect(logged_while { bundle_for(["ios"]) }.grep(/filtered|pruned/)).to be_empty
136
+ end
137
+ end
138
+
139
+ describe "when filter_by_platforms is nil" do
140
+ it "leaves the bundle untouched" do
141
+ before_filtering = unfiltered_bundle
142
+ after_filtering = bundle_for(nil)
143
+
144
+ expect(after_filtering[:events].map(&:event_name)).to eq(before_filtering[:events].map(&:event_name))
145
+ end
146
+ end
147
+ end
metadata CHANGED
@@ -1,16 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: evva
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.5
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - André Andrade
8
8
  - João Costa
9
9
  - Ricardo Trindade
10
- autorequire:
11
10
  bindir: bin
12
11
  cert_chain: []
13
- date: 2026-08-21 00:00:00.000000000 Z
12
+ date: 2026-10-13 00:00:00.000000000 Z
14
13
  dependencies:
15
14
  - !ruby/object:Gem::Dependency
16
15
  name: colorize
@@ -30,14 +29,14 @@ dependencies:
30
29
  name: csv
31
30
  requirement: !ruby/object:Gem::Requirement
32
31
  requirements:
33
- - - ">="
32
+ - - "~>"
34
33
  - !ruby/object:Gem::Version
35
34
  version: '3.0'
36
35
  type: :runtime
37
36
  prerelease: false
38
37
  version_requirements: !ruby/object:Gem::Requirement
39
38
  requirements:
40
- - - ">="
39
+ - - "~>"
41
40
  - !ruby/object:Gem::Version
42
41
  version: '3.0'
43
42
  - !ruby/object:Gem::Dependency
@@ -99,7 +98,9 @@ files:
99
98
  - lib/evva/version.rb
100
99
  - spec/evva_spec.rb
101
100
  - spec/fixtures/sample_public_enums.csv
101
+ - spec/fixtures/sample_public_enums_with_platform.csv
102
102
  - spec/fixtures/sample_public_events.csv
103
+ - spec/fixtures/sample_public_events_with_platform.csv
103
104
  - spec/fixtures/sample_public_people_properties.csv
104
105
  - spec/fixtures/test.yml
105
106
  - spec/lib/evva/config_spec.rb
@@ -108,12 +109,12 @@ files:
108
109
  - spec/lib/evva/logger_spec.rb
109
110
  - spec/lib/evva/object_extension_spec.rb
110
111
  - spec/lib/evva/swift_generator_spec.rb
112
+ - spec/platform_filter_spec.rb
111
113
  - spec/spec_helper.rb
112
114
  homepage: https://github.com/hole19/
113
115
  licenses:
114
116
  - MIT
115
117
  metadata: {}
116
- post_install_message:
117
118
  rdoc_options: []
118
119
  require_paths:
119
120
  - lib
@@ -128,8 +129,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
128
129
  - !ruby/object:Gem::Version
129
130
  version: '0'
130
131
  requirements: []
131
- rubygems_version: 3.5.3
132
- signing_key:
132
+ rubygems_version: 4.0.10
133
133
  specification_version: 4
134
134
  summary: An event generating service
135
135
  test_files: []