evva 0.8.5 → 0.9.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: a759833afbdc6f48b186bce87555b4fae3da30812de523ddae04468244a7aee9
4
+ data.tar.gz: 421625130a0be7491b0e49ea5b6cdb434b7cb3a6f7c52d20adf02ff6f11ca205
5
5
  SHA512:
6
- metadata.gz: 497f43e9b4d3a96c45cc514743e9c131ef92cb9a8a60e1f4c9314bd8b140ab708ce15fbb269583fc9d316b9499b6a028b70f1ca4127b0365ed783b60dd5e258b
7
- data.tar.gz: 41f177ed9ef2c34ba43fa1d1a3352e66509fc24b313dcc208680bee488b60ecd1cf3272ae4d957db46b744e06cc428bb68f2efb5dfeb499e74b7e65f1d33fd22
6
+ metadata.gz: a9bd2cc069ab15eed7a02c6ed444f22579236446a7a3b7b9c1772df26deefaf442dd568334ab0903059f056a65fc1a7ceadb3e6fd6df1975a6d097ab7bffeca8
7
+ data.tar.gz: d8f1f9574a97f4060924514d99102abcdad78d71d75cd0a378bb94423f1cd359de171908fe11c8579370035ddab2a31eb0e429e8a27b27aef1b56cd12ba9de92
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,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- evva (0.8.4)
4
+ evva (0.9.0)
5
5
  colorize (~> 1.0)
6
6
  csv (>= 3.0)
7
7
  safe_yaml (~> 1.0)
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,10 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.0] - 2026-09-04
6
+
7
+ - Add `Platform` column support in the events sheet and `filter_by_platforms` configuration to filter generated events by platform
8
+
5
9
  ## [0.8.5] - 2026-08-21
6
10
 
7
11
  - 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?
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.9.0".freeze
3
+ VERSION_UPDATED_AT = "2026-09-04".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
 
@@ -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,7 +1,7 @@
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.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - André Andrade
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2026-08-21 00:00:00.000000000 Z
13
+ date: 2026-09-04 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: colorize
@@ -30,14 +30,14 @@ dependencies:
30
30
  name: csv
31
31
  requirement: !ruby/object:Gem::Requirement
32
32
  requirements:
33
- - - ">="
33
+ - - "~>"
34
34
  - !ruby/object:Gem::Version
35
35
  version: '3.0'
36
36
  type: :runtime
37
37
  prerelease: false
38
38
  version_requirements: !ruby/object:Gem::Requirement
39
39
  requirements:
40
- - - ">="
40
+ - - "~>"
41
41
  - !ruby/object:Gem::Version
42
42
  version: '3.0'
43
43
  - !ruby/object:Gem::Dependency
@@ -99,7 +99,9 @@ files:
99
99
  - lib/evva/version.rb
100
100
  - spec/evva_spec.rb
101
101
  - spec/fixtures/sample_public_enums.csv
102
+ - spec/fixtures/sample_public_enums_with_platform.csv
102
103
  - spec/fixtures/sample_public_events.csv
104
+ - spec/fixtures/sample_public_events_with_platform.csv
103
105
  - spec/fixtures/sample_public_people_properties.csv
104
106
  - spec/fixtures/test.yml
105
107
  - spec/lib/evva/config_spec.rb
@@ -108,6 +110,7 @@ files:
108
110
  - spec/lib/evva/logger_spec.rb
109
111
  - spec/lib/evva/object_extension_spec.rb
110
112
  - spec/lib/evva/swift_generator_spec.rb
113
+ - spec/platform_filter_spec.rb
111
114
  - spec/spec_helper.rb
112
115
  homepage: https://github.com/hole19/
113
116
  licenses: