quonfig 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +4 -4
  4. data/lib/quonfig/evaluation_details.rb +60 -0
  5. data/lib/quonfig/options.rb +37 -16
  6. data/lib/quonfig/sse_config_client.rb +1 -1
  7. data/lib/quonfig/version.rb +5 -0
  8. data/lib/quonfig.rb +2 -1
  9. data/quonfig.gemspec +30 -163
  10. metadata +29 -182
  11. data/.claude/rules/constitution.md +0 -81
  12. data/.claude/rules/git-safety.md +0 -11
  13. data/.claude/rules/issue-tracking.md +0 -13
  14. data/.claude/rules/testing-workflow.md +0 -28
  15. data/.envrc.sample +0 -3
  16. data/.github/CODEOWNERS +0 -2
  17. data/.github/pull_request_template.md +0 -8
  18. data/.github/workflows/release.yml +0 -49
  19. data/.github/workflows/ruby.yml +0 -60
  20. data/.github/workflows/test.yaml +0 -40
  21. data/.rubocop.yml +0 -13
  22. data/.tool-versions +0 -1
  23. data/CLAUDE.md +0 -29
  24. data/CODEOWNERS +0 -1
  25. data/Gemfile +0 -26
  26. data/Gemfile.lock +0 -177
  27. data/Rakefile +0 -64
  28. data/VERSION +0 -1
  29. data/dev/allocation_stats +0 -60
  30. data/dev/benchmark +0 -40
  31. data/dev/console +0 -12
  32. data/dev/script_setup.rb +0 -18
  33. data/test/fixtures/datafile.json +0 -87
  34. data/test/integration/test_context_precedence.rb +0 -112
  35. data/test/integration/test_datadir_environment.rb +0 -54
  36. data/test/integration/test_dev_overrides.rb +0 -40
  37. data/test/integration/test_enabled.rb +0 -478
  38. data/test/integration/test_enabled_with_contexts.rb +0 -64
  39. data/test/integration/test_get.rb +0 -136
  40. data/test/integration/test_get_feature_flag.rb +0 -28
  41. data/test/integration/test_get_or_raise.rb +0 -60
  42. data/test/integration/test_get_weighted_values.rb +0 -34
  43. data/test/integration/test_helpers.rb +0 -667
  44. data/test/integration/test_helpers_test.rb +0 -73
  45. data/test/integration/test_post.rb +0 -44
  46. data/test/integration/test_telemetry.rb +0 -170
  47. data/test/support/common_helpers.rb +0 -106
  48. data/test/support/mock_base_client.rb +0 -27
  49. data/test/support/mock_config_loader.rb +0 -1
  50. data/test/test_bound_client.rb +0 -109
  51. data/test/test_caching_http_connection.rb +0 -218
  52. data/test/test_client.rb +0 -255
  53. data/test/test_client_network_mode.rb +0 -136
  54. data/test/test_client_telemetry.rb +0 -175
  55. data/test/test_config_loader.rb +0 -70
  56. data/test/test_context.rb +0 -139
  57. data/test/test_context_shape.rb +0 -37
  58. data/test/test_context_shape_aggregator.rb +0 -126
  59. data/test/test_datadir.rb +0 -203
  60. data/test/test_dev_context.rb +0 -163
  61. data/test/test_duration.rb +0 -37
  62. data/test/test_encryption.rb +0 -16
  63. data/test/test_evaluation_summaries_aggregator.rb +0 -180
  64. data/test/test_evaluator.rb +0 -285
  65. data/test/test_example_contexts_aggregator.rb +0 -119
  66. data/test/test_exponential_backoff.rb +0 -44
  67. data/test/test_fixed_size_hash.rb +0 -119
  68. data/test/test_helper.rb +0 -17
  69. data/test/test_http_connection.rb +0 -79
  70. data/test/test_internal_logger.rb +0 -34
  71. data/test/test_options.rb +0 -167
  72. data/test/test_rate_limit_cache.rb +0 -44
  73. data/test/test_reason.rb +0 -79
  74. data/test/test_rename.rb +0 -65
  75. data/test/test_resolver.rb +0 -291
  76. data/test/test_semantic_logger_filter.rb +0 -144
  77. data/test/test_semver.rb +0 -108
  78. data/test/test_should_log.rb +0 -186
  79. data/test/test_sse_config_client.rb +0 -297
  80. data/test/test_stdlib_formatter.rb +0 -195
  81. data/test/test_telemetry_reporter.rb +0 -209
  82. data/test/test_typed_getters.rb +0 -131
  83. data/test/test_types.rb +0 -141
  84. data/test/test_weighted_value_resolver.rb +0 -84
@@ -1,175 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'test_helper'
4
-
5
- # Verifies that Quonfig::Client#get feeds every evaluation into the
6
- # telemetry reporter's evaluation_summaries aggregator.
7
- class TestClientTelemetry < Minitest::Test
8
- CONFIG_KEY = 'my.flag'
9
-
10
- class FakeHttpConnection
11
- FakeResponse = Struct.new(:status)
12
- attr_reader :posts
13
- def initialize; @posts = []; end
14
- def post(path, body); @posts << [path, body]; FakeResponse.new(200); end
15
- end
16
-
17
- # Plain ConfigResponse-shaped hash (matches Datadir.to_config_response).
18
- def make_config(key:, value:, type: 'string', criteria: nil)
19
- {
20
- 'id' => 'cid-abc',
21
- 'key' => key,
22
- 'type' => 'config',
23
- 'valueType' => type,
24
- 'sendToClientSdk' => false,
25
- 'default' => {
26
- 'rules' => [
27
- {
28
- 'criteria' => criteria || [{ 'operator' => 'ALWAYS_TRUE' }],
29
- 'value' => { 'type' => type, 'value' => value }
30
- }
31
- ]
32
- },
33
- 'environment' => nil
34
- }
35
- end
36
-
37
- def make_client_with_telemetry(store)
38
- client = Quonfig::Client.new(Quonfig::Options.new, store: store)
39
-
40
- # The store-injection path skips initialize_telemetry by design (it's
41
- # for test/bootstrap mode), so we attach the reporter + aggregators
42
- # here explicitly to exercise the record path.
43
- summaries_agg = Quonfig::Telemetry::EvaluationSummariesAggregator.new(max_keys: 100)
44
- shape_agg = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 100)
45
- example_agg = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 100)
46
-
47
- options = Quonfig::Options.new(
48
- sdk_key: 'qf_sk_dev_abc_deadbeef',
49
- environment: 'development',
50
- api_urls: ['https://primary.example.com'],
51
- enable_sse: false,
52
- enable_polling: false,
53
- on_init_failure: Quonfig::Options::ON_INITIALIZATION_FAILURE::RETURN
54
- ).tap { |o| o.instance_variable_set(:@telemetry_destination, 'https://t.example.com') }
55
-
56
- fake_conn = FakeHttpConnection.new
57
- reporter = Quonfig::Telemetry::TelemetryReporter.new(
58
- options: options,
59
- instance_hash: client.instance_hash,
60
- context_shape_aggregator: shape_agg,
61
- example_contexts_aggregator: example_agg,
62
- evaluation_summaries_aggregator: summaries_agg,
63
- http_connection: fake_conn
64
- )
65
- client.instance_variable_set(:@telemetry_reporter, reporter)
66
-
67
- [client, reporter, summaries_agg, fake_conn]
68
- end
69
-
70
- def test_get_pushes_evaluation_into_summaries_aggregator
71
- store = Quonfig::ConfigStore.new
72
- store.set(CONFIG_KEY, make_config(key: CONFIG_KEY, value: 'hello'))
73
-
74
- client, _reporter, summaries_agg, _conn = make_client_with_telemetry(store)
75
- client.get(CONFIG_KEY, Quonfig::NO_DEFAULT_PROVIDED, 'user' => { 'key' => 'u1' })
76
-
77
- event = summaries_agg.drain_event
78
- refute_nil event, 'expected an evaluation_summaries event after Client#get'
79
-
80
- summary = event['summaries']['summaries'][0]
81
- assert_equal CONFIG_KEY, summary['key']
82
- assert_equal 'config', summary['type']
83
-
84
- counter = summary['counters'][0]
85
- assert_equal 'cid-abc', counter['configId']
86
- assert_equal 0, counter['conditionalValueIndex']
87
- assert_equal 1, counter['count']
88
- assert_equal({ 'string' => 'hello' }, counter['selectedValue'])
89
- # ALWAYS_TRUE on the only rule → STATIC (1)
90
- assert_equal 1, counter['reason']
91
- end
92
-
93
- def test_get_with_targeting_rule_reports_targeting_match
94
- store = Quonfig::ConfigStore.new
95
- store.set(CONFIG_KEY, make_config(
96
- key: CONFIG_KEY,
97
- value: 'targeted',
98
- criteria: [{
99
- 'operator' => 'PROP_IS_ONE_OF',
100
- 'propertyName' => 'user.tier',
101
- 'valueToMatch' => { 'type' => 'string_list', 'value' => ['pro'] }
102
- }]
103
- ))
104
-
105
- client, _reporter, summaries_agg, _conn = make_client_with_telemetry(store)
106
- client.get(CONFIG_KEY, 'fallback', 'user' => { 'key' => 'u1', 'tier' => 'pro' })
107
-
108
- counter = summaries_agg.drain_event['summaries']['summaries'][0]['counters'][0]
109
- # Config has a non-ALWAYS_TRUE rule → TARGETING_MATCH (2)
110
- assert_equal 2, counter['reason']
111
- end
112
-
113
- def test_repeated_get_increments_count_not_new_counters
114
- store = Quonfig::ConfigStore.new
115
- store.set(CONFIG_KEY, make_config(key: CONFIG_KEY, value: 'hello'))
116
-
117
- client, _reporter, summaries_agg, _conn = make_client_with_telemetry(store)
118
- 3.times { client.get(CONFIG_KEY, Quonfig::NO_DEFAULT_PROVIDED, 'user' => { 'key' => 'u1' }) }
119
-
120
- counters = summaries_agg.drain_event['summaries']['summaries'][0]['counters']
121
- assert_equal 1, counters.size, 'same evaluation should dedupe into one counter'
122
- assert_equal 3, counters[0]['count']
123
- end
124
-
125
- def test_missing_key_does_not_record_evaluation
126
- store = Quonfig::ConfigStore.new
127
- client, _reporter, summaries_agg, _conn = make_client_with_telemetry(store)
128
-
129
- client.get('does.not.exist', 'fallback')
130
- assert_nil summaries_agg.drain_event
131
- end
132
-
133
- # Regression: client used to hardcode weighted_value_index: nil, so split
134
- # variants never reported as REASON_SPLIT in telemetry.
135
- def test_weighted_value_records_split_reason_and_index
136
- weighted_config = {
137
- 'id' => 'cid-weighted',
138
- 'key' => 'feature-flag.weighted',
139
- 'type' => 'feature_flag',
140
- 'valueType' => 'string',
141
- 'sendToClientSdk' => false,
142
- 'default' => {
143
- 'rules' => [
144
- {
145
- 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }],
146
- 'value' => {
147
- 'type' => 'weighted_values',
148
- 'value' => {
149
- 'hashByPropertyName' => 'user.key',
150
- 'weightedValues' => [
151
- { 'value' => { 'type' => 'string', 'value' => 'control' }, 'weight' => 1 },
152
- { 'value' => { 'type' => 'string', 'value' => 'variant' }, 'weight' => 99 }
153
- ]
154
- }
155
- }
156
- }
157
- ]
158
- },
159
- 'environment' => nil
160
- }
161
-
162
- store = Quonfig::ConfigStore.new
163
- store.set('feature-flag.weighted', weighted_config)
164
-
165
- client, _reporter, summaries_agg, _conn = make_client_with_telemetry(store)
166
- client.get('feature-flag.weighted', Quonfig::NO_DEFAULT_PROVIDED, 'user' => { 'key' => 'u1' })
167
-
168
- counter = summaries_agg.drain_event['summaries']['summaries'][0]['counters'][0]
169
- assert_equal Quonfig::EvalResult::REASON_SPLIT, counter['reason'],
170
- 'weighted variant evaluation must report REASON_SPLIT (3)'
171
- refute_nil counter['weightedValueIndex'],
172
- 'weighted variant evaluation must report a weightedValueIndex'
173
- assert_kind_of Integer, counter['weightedValueIndex']
174
- end
175
- end
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'test_helper'
4
- require 'ostruct'
5
- require 'json'
6
-
7
- class TestConfigLoader < Minitest::Test
8
- def setup
9
- super
10
- options = Quonfig::Options.new(
11
- sdk_key: '1-test-sdk-key',
12
- api_urls: ['https://primary.example.test']
13
- )
14
- @loader = Quonfig::ConfigLoader.new(MockBaseClient.new(options))
15
- end
16
-
17
- def test_fetch_200_populates_configs_and_stores_etag_then_304_is_a_noop
18
- body = JSON.generate(
19
- 'configs' => [
20
- { 'key' => 'my.flag', 'type' => 'config', 'valueType' => 'bool',
21
- 'default' => { 'rules' => [] } }
22
- ],
23
- 'meta' => { 'version' => 'v1', 'environment' => 'production' }
24
- )
25
- ok_response = Faraday::Response.new(
26
- status: 200, body: body,
27
- response_headers: { 'ETag' => 'W/"etag-one"' }
28
- )
29
- not_modified = Faraday::Response.new(
30
- status: 304, body: '', response_headers: {}
31
- )
32
-
33
- observed_headers = []
34
-
35
- http_conn = Minitest::Mock.new
36
- http_conn.expect(:get, ok_response) do |path, headers|
37
- observed_headers << headers.dup
38
- path == '/api/v2/configs' && !headers.key?('If-None-Match')
39
- end
40
- http_conn.expect(:get, not_modified) do |path, headers|
41
- observed_headers << headers.dup
42
- path == '/api/v2/configs' && headers['If-None-Match'] == 'W/"etag-one"'
43
- end
44
-
45
- Quonfig::HttpConnection.stub :new, ->(_uri, _key) { http_conn } do
46
- assert_equal :updated, @loader.fetch!
47
- assert_equal :not_modified, @loader.fetch!
48
- end
49
-
50
- assert_equal 'W/"etag-one"', @loader.etag
51
- calc = @loader.calc_config
52
- assert calc.key?('my.flag'), "expected 'my.flag' to be loaded"
53
- assert_equal 'W/"etag-one"', observed_headers[1]['If-None-Match']
54
- http_conn.verify
55
- end
56
-
57
- def test_set_and_rm_preserved
58
- config = OpenStruct.new(key: 'x', rows: [1])
59
- @loader.set(config, :test)
60
- assert @loader.calc_config.key?('x')
61
-
62
- @loader.rm('x')
63
- refute @loader.calc_config.key?('x')
64
- end
65
-
66
- def test_no_highwater_mark_attribute
67
- refute @loader.respond_to?(:highwater_mark),
68
- 'highwater_mark should be removed from ConfigLoader'
69
- end
70
- end
data/test/test_context.rb DELETED
@@ -1,139 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'test_helper'
4
-
5
- class TestContext < Minitest::Test
6
- EXAMPLE_PROPERTIES = {
7
- user: { key: 'some-user-key', name: 'Ted' },
8
- team: { key: 'abc', plan: 'pro' }
9
- }.freeze
10
-
11
- def test_initialize_with_empty_context
12
- context = Quonfig::Context.new({})
13
- assert_empty context.contexts
14
- assert context.blank?
15
- end
16
-
17
- def test_initialize_with_hash
18
- context = Quonfig::Context.new(test: { foo: 'bar' })
19
- assert_equal 1, context.contexts.size
20
- assert_equal 'bar', context.get('test.foo')
21
- end
22
-
23
- def test_initialize_with_multiple_hashes
24
- context = Quonfig::Context.new(test: { foo: 'bar' }, other: { foo: 'baz' })
25
- assert_equal 2, context.contexts.size
26
- assert_equal 'bar', context.get('test.foo')
27
- assert_equal 'baz', context.get('other.foo')
28
- end
29
-
30
- def test_initialize_with_invalid_argument
31
- assert_raises(ArgumentError) { Quonfig::Context.new([]) }
32
- end
33
-
34
- def test_setting
35
- context = Quonfig::Context.new({})
36
- context.set('user', { key: 'value' })
37
- context.set(:other, { key: 'different', something: 'other' })
38
-
39
- assert_equal(
40
- stringify(user: { key: 'value' }, other: { key: 'different', something: 'other' }),
41
- context.to_h
42
- )
43
- end
44
-
45
- def test_getting
46
- context = Quonfig::Context.new(EXAMPLE_PROPERTIES)
47
- assert_equal('some-user-key', context.get('user.key'))
48
- assert_equal('pro', context.get('team.plan'))
49
- end
50
-
51
- def test_dot_notation_getting
52
- context = Quonfig::Context.new('user' => { 'key' => 'value' })
53
- assert_equal('value', context.get('user.key'))
54
- end
55
-
56
- def test_dot_notation_getting_with_symbols
57
- context = Quonfig::Context.new(user: { key: 'value' })
58
- assert_equal('value', context.get('user.key'))
59
- end
60
-
61
- def test_get_returns_nil_for_missing_property
62
- context = Quonfig::Context.new(user: { key: 'value' })
63
- assert_nil context.get('user.missing')
64
- assert_nil context.get('absent.key')
65
- end
66
-
67
- def test_clear
68
- context = Quonfig::Context.new(EXAMPLE_PROPERTIES)
69
- context.clear
70
-
71
- assert_empty context.to_h
72
- assert context.blank?
73
- end
74
-
75
- def test_to_h_stringifies_keys
76
- context = Quonfig::Context.new(EXAMPLE_PROPERTIES)
77
- assert_equal stringify(EXAMPLE_PROPERTIES), context.to_h
78
- end
79
-
80
- def test_legacy_flat_hash_shorthand_promotes_to_blank_named_context
81
- # Pre-named-contexts callers passed a flat Hash. The constructor still
82
- # accepts that shape and stuffs it under the empty-string named context.
83
- context = Quonfig::Context.new('foo' => 'bar')
84
- assert_equal 'bar', context.get('.foo')
85
- end
86
-
87
- def test_grouped_key_combines_named_contexts_by_key
88
- context = Quonfig::Context.new(
89
- user: { key: 'u1' },
90
- team: { key: 't1' }
91
- )
92
-
93
- # Mirrors sdk-node groupedKey: just the key/trackingId values, sorted
94
- # and pipe-joined. Contexts without a key/trackingId contribute
95
- # nothing — so the example aggregator drops anonymous contexts.
96
- assert_equal 't1|u1', context.grouped_key
97
- end
98
-
99
- def test_named_context_lookup_returns_namedcontext
100
- context = Quonfig::Context.new(user: { key: 'u1', name: 'Ted' })
101
- user = context.context('user')
102
-
103
- assert_kind_of Quonfig::Context::NamedContext, user
104
- assert_equal 'user', user.name
105
- assert_equal({ 'key' => 'u1', 'name' => 'Ted' }, user.to_h)
106
- end
107
-
108
- def test_named_context_lookup_for_missing_returns_empty_namedcontext
109
- context = Quonfig::Context.new(user: { key: 'u1' })
110
- missing = context.context('absent')
111
-
112
- assert_kind_of Quonfig::Context::NamedContext, missing
113
- assert_equal 'absent', missing.name
114
- assert_empty missing.to_h
115
- end
116
-
117
- def test_comparable
118
- a = Quonfig::Context.new(user: { key: 'u1' })
119
- b = Quonfig::Context.new(user: { key: 'u1' })
120
- c = Quonfig::Context.new(user: { key: 'u2' })
121
-
122
- assert_equal a, b
123
- refute_equal a, c
124
- end
125
-
126
- private
127
-
128
- def stringify(hash)
129
- hash.map { |k, v| [k.to_s, stringify_keys(v)] }.to_h
130
- end
131
-
132
- def stringify_keys(value)
133
- if value.is_a?(Hash)
134
- value.transform_keys(&:to_s)
135
- else
136
- value
137
- end
138
- end
139
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'test_helper'
4
-
5
- class TestContextShape < Minitest::Test
6
- class Email; end
7
-
8
- def test_field_type_number
9
- [
10
- [1, 1],
11
- [99_999_999_999_999_999_999_999_999_999_999_999_999_999_999, 1],
12
- [-99_999_999_999_999_999_999_999_999_999_999_999_999_999_999, 1],
13
-
14
- ['a', 2],
15
- ['99999999999999999999999999999999999999999999', 2],
16
-
17
- [1.0, 4],
18
- [99_999_999_999_999_999_999_999_999_999_999_999_999_999_999.0, 4],
19
- [-99_999_999_999_999_999_999_999_999_999_999_999_999_999_999.0, 4],
20
-
21
- [true, 5],
22
- [false, 5],
23
-
24
- [[], 10],
25
- [[1, 2, 3], 10],
26
- [%w[a b c], 10],
27
-
28
- # Unknown / custom types fall back to "string" (2).
29
- [Email.new, 2]
30
- ].each do |value, expected|
31
- actual = Quonfig::Telemetry::ContextShape.field_type_number(value)
32
-
33
- refute_nil actual, "Expected a value for input: #{value.inspect}"
34
- assert_equal expected, actual, "Expected #{expected} for #{value.inspect}"
35
- end
36
- end
37
- end
@@ -1,126 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'test_helper'
4
-
5
- class TestContextShapeAggregator < Minitest::Test
6
- CONTEXT_1 = Quonfig::Context.new(
7
- 'user' => {
8
- 'name' => 'user-name',
9
- 'email' => 'user.email',
10
- 'age' => 42.5
11
- },
12
- 'subscription' => {
13
- 'plan' => 'advanced',
14
- 'free' => false
15
- }
16
- ).freeze
17
-
18
- CONTEXT_2 = Quonfig::Context.new(
19
- 'user' => {
20
- 'name' => 'other-user-name',
21
- 'dob' => '2020-01-01'
22
- },
23
- 'device' => {
24
- 'name' => 'device-name',
25
- 'os' => 'os-name',
26
- 'version' => 3
27
- }
28
- ).freeze
29
-
30
- CONTEXT_3 = Quonfig::Context.new(
31
- 'subscription' => {
32
- 'plan' => 'pro',
33
- 'trial' => true
34
- }
35
- ).freeze
36
-
37
- def test_push_respects_max_shapes
38
- aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 9)
39
-
40
- aggregator.push(CONTEXT_1)
41
- aggregator.push(CONTEXT_2)
42
- assert_equal 9, aggregator.data.size
43
-
44
- # At the limit — further shapes get dropped.
45
- aggregator.push(CONTEXT_3)
46
- assert_equal 9, aggregator.data.size
47
-
48
- tuples = aggregator.data.to_a
49
- assert_includes tuples, ['user', 'name', 2]
50
- assert_includes tuples, ['user', 'email', 2]
51
- assert_includes tuples, ['user', 'age', 4]
52
- assert_includes tuples, ['subscription', 'plan', 2]
53
- assert_includes tuples, ['subscription', 'free', 5]
54
- assert_includes tuples, ['device', 'name', 2]
55
- assert_includes tuples, ['device', 'os', 2]
56
- assert_includes tuples, ['device', 'version', 1]
57
- end
58
-
59
- def test_prepare_data_folds_tuples_and_clears
60
- aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 1000)
61
-
62
- aggregator.push(CONTEXT_1)
63
- aggregator.push(CONTEXT_2)
64
- aggregator.push(CONTEXT_3)
65
-
66
- data = aggregator.prepare_data
67
-
68
- assert_equal %w[user subscription device].sort, data.keys.sort
69
-
70
- assert_equal(
71
- { 'name' => 2, 'email' => 2, 'dob' => 2, 'age' => 4 },
72
- data['user']
73
- )
74
-
75
- assert_equal(
76
- { 'plan' => 2, 'trial' => 5, 'free' => 5 },
77
- data['subscription']
78
- )
79
-
80
- assert_equal(
81
- { 'name' => 2, 'os' => 2, 'version' => 1 },
82
- data['device']
83
- )
84
-
85
- assert_equal [], aggregator.data.to_a
86
- end
87
-
88
- def test_drain_event_emits_api_telemetry_wire_shape
89
- aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 1000)
90
- aggregator.push(Quonfig::Context.new('user' => { 'key' => 'abc', 'age' => 42 }))
91
-
92
- event = aggregator.drain_event
93
-
94
- refute_nil event
95
- assert event.key?('contextShapes')
96
- shapes = event['contextShapes']['shapes']
97
- assert_equal 1, shapes.size
98
- assert_equal 'user', shapes[0]['name']
99
- assert_equal({ 'key' => 2, 'age' => 1 }, shapes[0]['fieldTypes'])
100
- end
101
-
102
- def test_drain_event_nil_when_empty
103
- aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 1000)
104
- assert_nil aggregator.drain_event
105
- end
106
-
107
- def test_push_dedupes_identical_shapes
108
- aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 1000)
109
-
110
- aggregator.push(Quonfig::Context.new('user' => { 'key' => 'a', 'age' => 1 }))
111
- aggregator.push(Quonfig::Context.new('user' => { 'key' => 'b', 'age' => 2 }))
112
-
113
- # Same (name, key, type) tuples should have been deduped by the Set.
114
- assert_equal 2, aggregator.data.size
115
- end
116
-
117
- def test_accepts_plain_hash_context
118
- aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 1000)
119
-
120
- aggregator.push('user' => { 'key' => 'abc', 'is_admin' => true })
121
-
122
- event = aggregator.drain_event
123
- refute_nil event
124
- assert_equal({ 'key' => 2, 'is_admin' => 5 }, event['contextShapes']['shapes'][0]['fieldTypes'])
125
- end
126
- end