evva 0.1.4.2 → 0.3.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 +5 -5
- data/.gitignore +1 -2
- data/.rspec +0 -1
- data/.rubocop_todo.yml +10 -10
- data/.travis.yml +1 -0
- data/Gemfile +9 -8
- data/Gemfile.lock +7 -11
- data/README.md +10 -0
- data/Rakefile +9 -0
- data/changelog.md +19 -2
- data/evva.gemspec +0 -3
- data/evva_config.yml +7 -3
- data/lib/evva/{mixpanel_enum.rb → analytics_enum.rb} +1 -1
- data/lib/evva/{mixpanel_event.rb → analytics_event.rb} +1 -1
- data/lib/evva/android_generator.rb +88 -69
- data/lib/evva/config.rb +13 -2
- data/lib/evva/google_sheet.rb +49 -46
- data/lib/evva/swift_generator.rb +44 -33
- data/lib/evva/version.rb +2 -2
- data/lib/evva.rb +7 -9
- data/spec/evva_spec.rb +1 -1
- data/spec/fixtures/sample_public_enums.csv +3 -0
- data/spec/fixtures/sample_public_events.csv +4 -0
- data/spec/fixtures/sample_public_people_properties.csv +3 -0
- data/spec/fixtures/test.yml +7 -4
- data/spec/lib/evva/android_generator_spec.rb +125 -116
- data/spec/lib/evva/config_spec.rb +8 -4
- data/spec/lib/evva/google_sheet_spec.rb +44 -74
- data/spec/lib/evva/swift_generator_spec.rb +100 -69
- metadata +10 -40
- data/lib/evva/data_source.rb +0 -23
- data/spec/fixtures/sample_public_enums.html +0 -1
- data/spec/fixtures/sample_public_info.html +0 -1
- data/spec/fixtures/sample_public_people_properties.html +0 -1
- data/spec/fixtures/sample_public_sheet.html +0 -1
- data/spec/lib/evva/data_source_spec.rb +0 -28
data/lib/evva/swift_generator.rb
CHANGED
@@ -10,29 +10,26 @@ module Evva
|
|
10
10
|
NATIVE_TYPES = %w[Int String Double Float Bool].freeze
|
11
11
|
|
12
12
|
def events(bundle, file_name)
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
13
|
+
header_footer_wrapper do
|
14
|
+
"""\tenum Event {
|
15
|
+
#{bundle.map { |e| event_case(e) }.join("\n")}
|
16
|
+
|
17
|
+
\t\tvar data: EventData {
|
18
|
+
\t\t\tswitch self {
|
19
|
+
#{bundle.map { |e| event_data(e) }.join("\n\n")}
|
20
|
+
\t\t\t}
|
21
|
+
\t\t}
|
22
|
+
\t}"""
|
17
23
|
end
|
18
|
-
event_file += "\n\t\tvar data: EventData {\n"
|
19
|
-
event_file += "\t\t\tswitch self {\n"
|
20
|
-
bundle.each do |event|
|
21
|
-
event_file += event_data(event)
|
22
|
-
end
|
23
|
-
event_file += "\t\t\t}\n"
|
24
|
-
event_file += "\t\t}\n\n"
|
25
|
-
event_file += "\t}"
|
26
|
-
event_file += EXTENSION_FOOTER
|
27
24
|
end
|
28
25
|
|
29
26
|
def event_case(event_data)
|
30
27
|
function_name = camelize(event_data.event_name)
|
31
28
|
if event_data.properties.empty?
|
32
|
-
"\t\tcase #{function_name}
|
29
|
+
"\t\tcase #{function_name}"
|
33
30
|
else
|
34
31
|
trimmed_properties = event_data.properties.map { |k, v| k.to_s + ': ' + native_type(v) }.join(", ")
|
35
|
-
"\t\tcase #{function_name}(#{trimmed_properties})
|
32
|
+
"\t\tcase #{function_name}(#{trimmed_properties})"
|
36
33
|
end
|
37
34
|
end
|
38
35
|
|
@@ -40,14 +37,14 @@ module Evva
|
|
40
37
|
function_name = camelize(event_data.event_name)
|
41
38
|
if event_data.properties.empty?
|
42
39
|
function_body = "\t\t\tcase .#{function_name}:\n" \
|
43
|
-
"\t\t\t\treturn EventData(name: \"#{event_data.event_name}\")
|
40
|
+
"\t\t\t\treturn EventData(name: \"#{event_data.event_name}\")"
|
44
41
|
else
|
45
42
|
function_header = prepend_let(event_data.properties)
|
46
43
|
function_arguments = dictionary_pairs(event_data.properties)
|
47
44
|
function_body = "\t\t\tcase .#{function_name}(#{function_header}):\n"\
|
48
45
|
"\t\t\t\treturn EventData(name: \"#{event_data.event_name}\", properties: [\n"\
|
49
46
|
"\t\t\t\t\t#{function_arguments.join(",\n\t\t\t\t\t")} ]\n"\
|
50
|
-
"\t\t\t\t)
|
47
|
+
"\t\t\t\t)"
|
51
48
|
end
|
52
49
|
function_body
|
53
50
|
end
|
@@ -57,23 +54,39 @@ module Evva
|
|
57
54
|
end
|
58
55
|
|
59
56
|
def people_properties(people_bundle, file_name)
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
57
|
+
header_footer_wrapper do
|
58
|
+
props = "\tenum Property: String {\n"
|
59
|
+
people_bundle.each do |prop|
|
60
|
+
props << "\t\tcase #{camelize(prop)} = \"#{prop}\"\n"
|
61
|
+
end
|
62
|
+
props << "\t}"
|
64
63
|
end
|
65
|
-
properties += "\t}"
|
66
|
-
properties += EXTENSION_FOOTER
|
67
64
|
end
|
68
65
|
|
69
|
-
def
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
66
|
+
def special_property_enums(enums)
|
67
|
+
header_footer_wrapper do
|
68
|
+
enums.map do |enum|
|
69
|
+
body = "\tenum #{enum.enum_name}: String {\n"
|
70
|
+
enum.values.each do |value|
|
71
|
+
body << "\t\tcase #{camelize(value)} = \"#{value}\"\n"
|
72
|
+
end
|
73
|
+
body << "\t}"
|
74
|
+
end.join("\n\n")
|
74
75
|
end
|
75
|
-
|
76
|
-
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def header_footer_wrapper
|
81
|
+
"""// This file was automatically generated by evva: https://github.com/hole19/evva
|
82
|
+
|
83
|
+
import Foundation
|
84
|
+
|
85
|
+
extension Analytics {
|
86
|
+
|
87
|
+
#{yield.gsub("\t", " ")}
|
88
|
+
}
|
89
|
+
"""
|
77
90
|
end
|
78
91
|
|
79
92
|
def dictionary_pairs(props)
|
@@ -89,8 +102,6 @@ module Evva
|
|
89
102
|
end
|
90
103
|
end
|
91
104
|
|
92
|
-
private
|
93
|
-
|
94
105
|
def is_raw_representable_property?(type)
|
95
106
|
!NATIVE_TYPES.include?(native_type(type).chomp('?'))
|
96
107
|
end
|
@@ -108,7 +119,7 @@ module Evva
|
|
108
119
|
end
|
109
120
|
|
110
121
|
def camelize(term)
|
111
|
-
string = term.to_s
|
122
|
+
string = term.to_s.tr(' ', '_').downcase
|
112
123
|
string = string.sub(/^(?:#{@acronym_regex}(?=\b|[A-Z_])|\w)/) { |match| match.downcase }
|
113
124
|
string.gsub!(/(?:_|(\/))([a-z\d]*)/i) { "#{$1}#{$2.capitalize}" }
|
114
125
|
string.gsub!("/".freeze, "::".freeze)
|
data/lib/evva/version.rb
CHANGED
data/lib/evva.rb
CHANGED
@@ -5,9 +5,8 @@ require 'evva/logger'
|
|
5
5
|
require 'evva/google_sheet'
|
6
6
|
require 'evva/config'
|
7
7
|
require 'evva/file_reader'
|
8
|
-
require 'evva/
|
9
|
-
require 'evva/
|
10
|
-
require 'evva/mixpanel_enum'
|
8
|
+
require 'evva/analytics_event'
|
9
|
+
require 'evva/analytics_enum'
|
11
10
|
require 'evva/object_extension'
|
12
11
|
require 'evva/version'
|
13
12
|
require 'evva/android_generator'
|
@@ -27,7 +26,7 @@ module Evva
|
|
27
26
|
bundle = analytics_data(config: config.data_source)
|
28
27
|
case config.type.downcase
|
29
28
|
when 'android'
|
30
|
-
generator = Evva::AndroidGenerator.new
|
29
|
+
generator = Evva::AndroidGenerator.new(config.package_name)
|
31
30
|
evva_write(bundle, generator, config, 'kt')
|
32
31
|
when 'ios'
|
33
32
|
generator = Evva::SwiftGenerator.new
|
@@ -44,20 +43,19 @@ module Evva
|
|
44
43
|
path = "#{configuration.out_path}/#{configuration.event_enum_file_name}.#{extension}"
|
45
44
|
write_to_file(path, generator.event_enum(bundle[:events], configuration.event_enum_file_name))
|
46
45
|
end
|
46
|
+
|
47
47
|
path = "#{configuration.out_path}/#{configuration.people_file_name}.#{extension}"
|
48
48
|
write_to_file(path, generator.people_properties(bundle[:people], configuration.people_file_name))
|
49
49
|
|
50
|
-
|
51
|
-
|
52
|
-
write_to_file(path, generator.special_property_enum(enum))
|
53
|
-
end
|
50
|
+
path = "#{configuration.out_path}/#{configuration.special_enum_file_name}.#{extension}"
|
51
|
+
write_to_file(path, generator.special_property_enums(bundle[:enums]))
|
54
52
|
end
|
55
53
|
|
56
54
|
def analytics_data(config:)
|
57
55
|
source =
|
58
56
|
case config[:type]
|
59
57
|
when 'google_sheet'
|
60
|
-
Evva::GoogleSheet.new(config[:
|
58
|
+
Evva::GoogleSheet.new(config[:events_url], config[:people_properties_url], config[:enum_classes_url])
|
61
59
|
end
|
62
60
|
events_bundle = {}
|
63
61
|
events_bundle[:events] = source.events
|
data/spec/evva_spec.rb
CHANGED
@@ -7,7 +7,7 @@ describe Evva do
|
|
7
7
|
before do
|
8
8
|
allow_any_instance_of(Evva::FileReader).to receive(:open_file).and_return(file)
|
9
9
|
allow_any_instance_of(Evva::GoogleSheet).to receive(:events).and_return(
|
10
|
-
[Evva::
|
10
|
+
[Evva::AnalyticsEvent.new('trackEvent',[])])
|
11
11
|
|
12
12
|
allow_any_instance_of(Evva::GoogleSheet).to receive(:people_properties).and_return([])
|
13
13
|
allow_any_instance_of(Evva::GoogleSheet).to receive(:enum_classes).and_return([])
|
data/spec/fixtures/test.yml
CHANGED
@@ -2,9 +2,12 @@ type: Android
|
|
2
2
|
|
3
3
|
data_source:
|
4
4
|
type: google_sheet
|
5
|
-
|
5
|
+
events_url: https://path-to-csv
|
6
|
+
people_properties_url: https://path-to-csv
|
7
|
+
enum_classes_url: https://path-to-csv
|
6
8
|
|
7
9
|
out_path: analytics
|
8
|
-
event_file_name:
|
9
|
-
event_enum_file_name:
|
10
|
-
people_file_name:
|
10
|
+
event_file_name: AnalyticsEvent
|
11
|
+
event_enum_file_name: AnalyticsEvents
|
12
|
+
people_file_name: AnalyticsProperties
|
13
|
+
package_name: com.package.name.analytics
|
@@ -1,135 +1,144 @@
|
|
1
1
|
describe Evva::AndroidGenerator do
|
2
|
-
let(:generator) { described_class.new }
|
2
|
+
let(:generator) { described_class.new("com.hole19golf.hole19.analytics") }
|
3
3
|
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
4
|
+
describe '#events' do
|
5
|
+
subject { generator.events(events, "AnalyticsEvent") }
|
6
|
+
|
7
|
+
let(:events) { [
|
8
|
+
Evva::AnalyticsEvent.new('cp_page_view'),
|
9
|
+
Evva::AnalyticsEvent.new('cp_page_view_a', { course_id: 'Long', course_name: 'String' }),
|
10
|
+
Evva::AnalyticsEvent.new('cp_page_view_b', { course_id: 'Long', course_name: 'String', from_screen: 'CourseProfileSource' }),
|
11
|
+
Evva::AnalyticsEvent.new('cp_page_view_c', { course_id: 'Long', course_name: 'String', from_screen: 'CourseProfileSource?' }),
|
12
|
+
Evva::AnalyticsEvent.new('cp_page_view_d', { course_id: 'Long?', course_name: 'String' })
|
13
|
+
] }
|
14
|
+
|
15
|
+
let(:expected) {
|
16
|
+
<<-Kotlin
|
17
|
+
package com.hole19golf.hole19.analytics
|
18
|
+
|
19
|
+
/**
|
20
|
+
* This file was automatically generated by evva: https://github.com/hole19/evva
|
21
|
+
*/
|
22
|
+
|
23
|
+
sealed class AnalyticsEvent(event: AnalyticsEvents) {
|
24
|
+
val name = event.key
|
25
|
+
|
26
|
+
open val properties: Map<String, Any?>? = null
|
8
27
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
context 'event has optional properties' do
|
55
|
-
let(:event) { Evva::MixpanelEvent.new('cp_page_view', { course_id: 'Long', course_name: 'String', from_screen: 'CourseProfileSource?' }) }
|
56
|
-
let(:expected) { <<-Kotlin
|
57
|
-
open fun trackCpPageView(course_id: Long, course_name: String, from_screen: CourseProfileSource?) {
|
58
|
-
val properties = JSONObject().apply {
|
59
|
-
put("course_id", course_id)
|
60
|
-
put("course_name", course_name)
|
61
|
-
from_screen?.let { put("from_screen", it.key) }
|
62
|
-
}
|
63
|
-
mixpanelMask.trackEvent(MixpanelEvent.CP_PAGE_VIEW, properties)
|
64
|
-
}
|
65
|
-
Kotlin
|
66
|
-
}
|
67
|
-
it { should eq trim_spaces(expected) }
|
68
|
-
end
|
69
|
-
|
70
|
-
context 'event has optional but not special properties' do
|
71
|
-
let(:event) { Evva::MixpanelEvent.new('cp_page_view', { course_id: 'Long?', course_name: 'String' }) }
|
72
|
-
let(:expected) { <<-Kotlin
|
73
|
-
open fun trackCpPageView(course_id: Long?, course_name: String) {
|
74
|
-
val properties = JSONObject().apply {
|
75
|
-
course_id?.let { put("course_id", it) }
|
76
|
-
put("course_name", course_name)
|
77
|
-
}
|
78
|
-
mixpanelMask.trackEvent(MixpanelEvent.CP_PAGE_VIEW, properties)
|
79
|
-
}
|
80
|
-
Kotlin
|
81
|
-
}
|
82
|
-
it { should eq trim_spaces(expected) }
|
83
|
-
end
|
28
|
+
object CpPageView : AnalyticsEvent(AnalyticsEvents.CP_PAGE_VIEW)
|
29
|
+
|
30
|
+
data class CpPageViewA(
|
31
|
+
val courseId: Long, val courseName: String
|
32
|
+
) : AnalyticsEvent(AnalyticsEvents.CP_PAGE_VIEW_A) {
|
33
|
+
override val properties = mapOf(
|
34
|
+
"course_id" to courseId,
|
35
|
+
"course_name" to courseName
|
36
|
+
)
|
37
|
+
}
|
38
|
+
|
39
|
+
data class CpPageViewB(
|
40
|
+
val courseId: Long, val courseName: String, val fromScreen: CourseProfileSource
|
41
|
+
) : AnalyticsEvent(AnalyticsEvents.CP_PAGE_VIEW_B) {
|
42
|
+
override val properties = mapOf(
|
43
|
+
"course_id" to courseId,
|
44
|
+
"course_name" to courseName,
|
45
|
+
"from_screen" to fromScreen.key
|
46
|
+
)
|
47
|
+
}
|
48
|
+
|
49
|
+
data class CpPageViewC(
|
50
|
+
val courseId: Long, val courseName: String, val fromScreen: CourseProfileSource?
|
51
|
+
) : AnalyticsEvent(AnalyticsEvents.CP_PAGE_VIEW_C) {
|
52
|
+
override val properties = mapOf(
|
53
|
+
"course_id" to courseId,
|
54
|
+
"course_name" to courseName,
|
55
|
+
"from_screen" to fromScreen?.key
|
56
|
+
)
|
57
|
+
}
|
58
|
+
|
59
|
+
data class CpPageViewD(
|
60
|
+
val courseId: Long?, val courseName: String
|
61
|
+
) : AnalyticsEvent(AnalyticsEvents.CP_PAGE_VIEW_D) {
|
62
|
+
override val properties = mapOf(
|
63
|
+
"course_id" to courseId,
|
64
|
+
"course_name" to courseName
|
65
|
+
)
|
66
|
+
}
|
67
|
+
}
|
68
|
+
Kotlin
|
69
|
+
}
|
70
|
+
|
71
|
+
it { should eq expected }
|
84
72
|
end
|
85
73
|
|
86
|
-
describe '#
|
87
|
-
subject {
|
88
|
-
let(:
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
74
|
+
describe '#special_property_enums' do
|
75
|
+
subject { generator.special_property_enums(enums) }
|
76
|
+
let(:enums) { [
|
77
|
+
Evva::AnalyticsEnum.new('CourseProfileSource', ['course_discovery', 'synced_courses']),
|
78
|
+
Evva::AnalyticsEnum.new('PremiumFrom', ['Course Profile', 'Round Setup'])
|
79
|
+
] }
|
80
|
+
let(:expected) {
|
81
|
+
<<-Kotlin
|
82
|
+
package com.hole19golf.hole19.analytics
|
83
|
+
|
84
|
+
/**
|
85
|
+
* This file was automatically generated by evva: https://github.com/hole19/evva
|
86
|
+
*/
|
87
|
+
|
88
|
+
enum class CourseProfileSource(val key: String) {
|
89
|
+
COURSE_DISCOVERY("course_discovery"),
|
90
|
+
SYNCED_COURSES("synced_courses");
|
91
|
+
}
|
92
|
+
|
93
|
+
enum class PremiumFrom(val key: String) {
|
94
|
+
COURSE_PROFILE("Course Profile"),
|
95
|
+
ROUND_SETUP("Round Setup");
|
96
|
+
}
|
97
|
+
Kotlin
|
97
98
|
}
|
98
|
-
it { should eq
|
99
|
+
it { should eq expected }
|
99
100
|
end
|
100
101
|
|
101
102
|
describe '#event_enum' do
|
102
|
-
subject {
|
103
|
+
subject { generator.event_enum(event_bundle, 'AnalyticsEvents') }
|
103
104
|
let(:event_bundle) { [
|
104
|
-
Evva::
|
105
|
-
Evva::
|
105
|
+
Evva::AnalyticsEvent.new('nav_feed_tap', {}),
|
106
|
+
Evva::AnalyticsEvent.new('nav_performance_tap', {})
|
106
107
|
] }
|
107
|
-
let(:expected) {
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
108
|
+
let(:expected) {
|
109
|
+
<<-Kotlin
|
110
|
+
package com.hole19golf.hole19.analytics
|
111
|
+
|
112
|
+
/**
|
113
|
+
* This file was automatically generated by evva: https://github.com/hole19/evva
|
114
|
+
*/
|
115
|
+
|
116
|
+
enum class AnalyticsEvents(val key: String) {
|
117
|
+
NAV_FEED_TAP("nav_feed_tap"),
|
118
|
+
NAV_PERFORMANCE_TAP("nav_performance_tap");
|
119
|
+
}
|
120
|
+
Kotlin
|
116
121
|
}
|
117
|
-
it { should eq
|
122
|
+
it { should eq expected }
|
118
123
|
end
|
119
124
|
|
120
125
|
describe '#people_properties' do
|
121
|
-
subject {
|
126
|
+
subject { generator.people_properties(people_bundle, 'AnalyticsProperties') }
|
122
127
|
let(:people_bundle) { ['rounds_with_wear', 'friends_from_facebook'] }
|
123
|
-
let(:expected) {
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
128
|
+
let(:expected) {
|
129
|
+
<<-Kotlin
|
130
|
+
package com.hole19golf.hole19.analytics
|
131
|
+
|
132
|
+
/**
|
133
|
+
* This file was automatically generated by evva: https://github.com/hole19/evva
|
134
|
+
*/
|
135
|
+
|
136
|
+
enum class AnalyticsProperties(val key: String) {
|
137
|
+
ROUNDS_WITH_WEAR("rounds_with_wear"),
|
138
|
+
FRIENDS_FROM_FACEBOOK("friends_from_facebook");
|
139
|
+
}
|
140
|
+
Kotlin
|
132
141
|
}
|
133
|
-
it { should eq
|
142
|
+
it { should eq expected }
|
134
143
|
end
|
135
144
|
end
|
@@ -5,13 +5,16 @@ describe Evva::Config do
|
|
5
5
|
{
|
6
6
|
type: 'EvvaOS',
|
7
7
|
data_source: {
|
8
|
-
type:
|
9
|
-
|
8
|
+
type: 'google_sheet',
|
9
|
+
events_url: 'https://events.csv',
|
10
|
+
people_properties_url: 'https://people_properties.csv',
|
11
|
+
enum_classes_url: 'https://enum_classes.csv',
|
10
12
|
},
|
11
13
|
out_path: 'clear/path/to/event',
|
12
14
|
event_file_name: 'event/file/name',
|
13
15
|
people_file_name: 'people/file/name',
|
14
|
-
event_enum_file_name: 'event/enum/file'
|
16
|
+
event_enum_file_name: 'event/enum/file',
|
17
|
+
package_name: 'com.package.name.analytics'
|
15
18
|
}
|
16
19
|
end
|
17
20
|
|
@@ -26,11 +29,12 @@ describe Evva::Config do
|
|
26
29
|
its(:event_file_name) { should eq('event/file/name') }
|
27
30
|
its(:people_file_name) { should eq('people/file/name') }
|
28
31
|
its(:event_enum_file_name) { should eq 'event/enum/file' }
|
32
|
+
its(:package_name) { should eq 'com.package.name.analytics' }
|
29
33
|
|
30
34
|
describe '#data_source' do
|
31
35
|
subject(:data_source) { config.data_source }
|
32
36
|
|
33
|
-
it { should eq(type: 'google_sheet',
|
37
|
+
it { should eq(type: 'google_sheet', events_url: 'https://events.csv', people_properties_url: 'https://people_properties.csv', enum_classes_url: 'https://enum_classes.csv') }
|
34
38
|
|
35
39
|
context 'when given an unknown type data source' do
|
36
40
|
before { hash[:data_source] = { type: 'i_dunno' } }
|