@reachy/audience-module 1.0.19 → 1.0.21

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.
@@ -0,0 +1,198 @@
1
+ import { QueryBuilder } from '../builders/QueryBuilder'
2
+
3
+ function createMockQuery() {
4
+ const calls: Array<{ method: string; args: any[] }> = []
5
+ const handler: ProxyHandler<any> = {
6
+ get(_target, prop) {
7
+ if (prop === '_calls') return calls
8
+ return (...args: any[]) => {
9
+ calls.push({ method: String(prop), args })
10
+ return new Proxy({}, handler)
11
+ }
12
+ }
13
+ }
14
+ return new Proxy({}, handler)
15
+ }
16
+
17
+ describe('QueryBuilder', () => {
18
+ describe('buildFilters', () => {
19
+ it('adds organization_id and project_id to query', () => {
20
+ const query = createMockQuery()
21
+ QueryBuilder.buildFilters(query, { groups: [] }, 'org-1', 'proj-1')
22
+ const calls = (query as any)._calls
23
+ expect(calls[0]).toEqual({ method: 'eq', args: ['organization_id', 'org-1'] })
24
+ })
25
+
26
+ it('applies groups criteria with OR', () => {
27
+ const query = createMockQuery()
28
+ const criteria = {
29
+ groups: [{
30
+ rules: [
31
+ { field: 'email', operator: 'equals', value: 'a@b.com' },
32
+ { field: 'name', operator: 'contains', value: 'John' }
33
+ ]
34
+ }]
35
+ }
36
+ QueryBuilder.buildFilters(query, criteria as any, 'org', 'proj')
37
+ // Should end up calling .or() at some point
38
+ const allCalls = (query as any)._calls
39
+ expect(allCalls.length).toBeGreaterThan(0)
40
+ })
41
+
42
+ it('returns query unchanged when no criteria format matches', () => {
43
+ const query = createMockQuery()
44
+ QueryBuilder.buildFilters(query, {} as any, 'org', 'proj')
45
+ const calls = (query as any)._calls
46
+ // Only eq calls for org/proj
47
+ expect(calls[0].method).toBe('eq')
48
+ })
49
+ })
50
+
51
+ describe('buildConditionString (via groups)', () => {
52
+ function buildCondition(field: string, operator: string, value: any): string | null {
53
+ const query = createMockQuery()
54
+ const criteria = {
55
+ groups: [{ rules: [{ field, operator, value }] }]
56
+ }
57
+ QueryBuilder.buildFilters(query, criteria as any, 'org', 'proj')
58
+ // The last call should be or() with the condition string
59
+ const allCalls = (query as any)._calls
60
+ const orCall = allCalls.find((c: any) => c.method === 'or')
61
+ return orCall ? orCall.args[0] : null
62
+ }
63
+
64
+ it('builds equals condition', () => {
65
+ expect(buildCondition('email', 'equals', 'a@b.com')).toBe('email.eq.a@b.com')
66
+ })
67
+
68
+ it('builds eq alias condition', () => {
69
+ expect(buildCondition('email', 'eq', 'a@b.com')).toBe('email.eq.a@b.com')
70
+ })
71
+
72
+ it('builds not_equals condition', () => {
73
+ expect(buildCondition('email', 'not_equals', 'x')).toBe('email.neq.x')
74
+ })
75
+
76
+ it('builds contains/ilike condition', () => {
77
+ expect(buildCondition('name', 'contains', 'john')).toBe('name.ilike.*john*')
78
+ })
79
+
80
+ it('builds starts_with condition', () => {
81
+ expect(buildCondition('name', 'starts_with', 'Jo')).toBe('name.ilike.Jo*')
82
+ })
83
+
84
+ it('builds ends_with condition', () => {
85
+ expect(buildCondition('name', 'ends_with', 'hn')).toBe('name.ilike.*hn')
86
+ })
87
+
88
+ it('builds greater_than condition', () => {
89
+ expect(buildCondition('age', 'greater_than', 18)).toBe('age.gt.18')
90
+ })
91
+
92
+ it('builds less_than condition', () => {
93
+ expect(buildCondition('age', 'less_than', 65)).toBe('age.lt.65')
94
+ })
95
+
96
+ it('builds is_empty condition', () => {
97
+ expect(buildCondition('phone', 'is_empty', null)).toBe('phone.is.null')
98
+ })
99
+
100
+ it('builds is_not_empty condition', () => {
101
+ expect(buildCondition('phone', 'is_not_empty', null)).toBe('phone.not.is.null')
102
+ })
103
+
104
+ it('returns null for unknown operator', () => {
105
+ expect(buildCondition('x', 'unknown_op', 'y')).toBeNull()
106
+ })
107
+
108
+ it('returns null when field is missing', () => {
109
+ expect(buildCondition('', 'equals', 'x')).toBeNull()
110
+ })
111
+
112
+ it('maps custom_field with customFieldKey', () => {
113
+ const query = createMockQuery()
114
+ const criteria = {
115
+ groups: [{
116
+ rules: [{ field: 'custom_field', operator: 'equals', value: 'vip', customFieldKey: 'plan' }]
117
+ }]
118
+ }
119
+ QueryBuilder.buildFilters(query, criteria as any, 'org', 'proj')
120
+ const allCalls = (query as any)._calls
121
+ const orCall = allCalls.find((c: any) => c.method === 'or')
122
+ expect(orCall?.args[0]).toContain('properties->>plan')
123
+ })
124
+ })
125
+
126
+ describe('filters format (v1)', () => {
127
+ it('applies OR filter groups', () => {
128
+ const query = createMockQuery()
129
+ const criteria = {
130
+ filters: [{
131
+ id: 'f1',
132
+ operator: 'OR',
133
+ conditions: [
134
+ { id: 'c1', field: 'email', operator: 'equals', value: 'a@b.com', logicalOperator: 'OR' },
135
+ { id: 'c2', field: 'name', operator: 'contains', value: 'Jo', logicalOperator: 'OR' }
136
+ ]
137
+ }]
138
+ }
139
+ QueryBuilder.buildFilters(query, criteria as any, 'org', 'proj')
140
+ const allCalls = (query as any)._calls
141
+ const orCall = allCalls.find((c: any) => c.method === 'or')
142
+ expect(orCall).toBeDefined()
143
+ })
144
+
145
+ it('applies AND filter groups as individual conditions', () => {
146
+ const query = createMockQuery()
147
+ const criteria = {
148
+ filters: [{
149
+ id: 'f1',
150
+ operator: 'AND',
151
+ conditions: [
152
+ { id: 'c1', field: 'email', operator: 'equals', value: 'a@b.com', logicalOperator: 'AND' }
153
+ ]
154
+ }]
155
+ }
156
+ QueryBuilder.buildFilters(query, criteria as any, 'org', 'proj')
157
+ const allCalls = (query as any)._calls
158
+ const eqCalls = allCalls.filter((c: any) => c.method === 'eq')
159
+ expect(eqCalls.length).toBeGreaterThanOrEqual(2) // org + proj + email
160
+ })
161
+ })
162
+
163
+ describe('conditions format', () => {
164
+ it('applies OR conditions using .or()', () => {
165
+ const query = createMockQuery()
166
+ const criteria = {
167
+ conditions: [{
168
+ groupId: 'g1',
169
+ operator: 'OR',
170
+ conditions: [
171
+ { field: 'email', operator: 'equals', value: 'a@b.com' }
172
+ ]
173
+ }]
174
+ }
175
+ QueryBuilder.buildFilters(query, criteria as any, 'org', 'proj')
176
+ const allCalls = (query as any)._calls
177
+ const orCall = allCalls.find((c: any) => c.method === 'or')
178
+ expect(orCall).toBeDefined()
179
+ })
180
+
181
+ it('applies AND conditions individually', () => {
182
+ const query = createMockQuery()
183
+ const criteria = {
184
+ conditions: [{
185
+ groupId: 'g1',
186
+ operator: 'AND',
187
+ conditions: [
188
+ { field: 'is_subscribed', operator: 'eq', value: true }
189
+ ]
190
+ }]
191
+ }
192
+ QueryBuilder.buildFilters(query, criteria as any, 'org', 'proj')
193
+ const allCalls = (query as any)._calls
194
+ const eqCalls = allCalls.filter((c: any) => c.method === 'eq')
195
+ expect(eqCalls.length).toBeGreaterThanOrEqual(2)
196
+ })
197
+ })
198
+ })
@@ -0,0 +1,284 @@
1
+ import { RfmEngine, RfmContactRow, RfmEventRow, RfmComputeOptions, RfmScoredContact } from '../engine/RfmEngine'
2
+
3
+ const NOW = new Date('2025-03-01T00:00:00Z')
4
+
5
+ function makeContacts(count: number): RfmContactRow[] {
6
+ return Array.from({ length: count }, (_, i) => ({
7
+ id: `c${i + 1}`,
8
+ reachy_id: `r${i + 1}`,
9
+ email: `user${i + 1}@test.com`,
10
+ phone: `+5511${String(i + 1).padStart(9, '0')}`,
11
+ is_subscribed: true,
12
+ }))
13
+ }
14
+
15
+ function makeEvents(contactId: string, eventName: string, dates: string[]): RfmEventRow[] {
16
+ return dates.map(d => ({
17
+ contact_id: contactId,
18
+ event_name: eventName,
19
+ event_timestamp: d,
20
+ }))
21
+ }
22
+
23
+ describe('RfmEngine', () => {
24
+ describe('aggregateFromEvents', () => {
25
+ const contacts = makeContacts(3)
26
+ const opts: RfmComputeOptions = {
27
+ from: '2025-01-01T00:00:00Z',
28
+ to: '2025-03-01T00:00:00Z',
29
+ rfEventName: 'purchase',
30
+ }
31
+
32
+ it('aggregates events per contact within time window', () => {
33
+ const events: RfmEventRow[] = [
34
+ ...makeEvents('c1', 'purchase', ['2025-01-15', '2025-02-20']),
35
+ ...makeEvents('c2', 'purchase', ['2025-02-01']),
36
+ ]
37
+
38
+ const result = RfmEngine.aggregateFromEvents(contacts, events, opts)
39
+ expect(result).toHaveLength(2)
40
+
41
+ const c1 = result.find(r => r.contact_id === 'c1')!
42
+ expect(c1.event_count).toBe(2)
43
+ expect(c1.last_ts).toEqual(new Date('2025-02-20'))
44
+
45
+ const c2 = result.find(r => r.contact_id === 'c2')!
46
+ expect(c2.event_count).toBe(1)
47
+ })
48
+
49
+ it('filters events by rfEventName', () => {
50
+ const events: RfmEventRow[] = [
51
+ ...makeEvents('c1', 'purchase', ['2025-01-15']),
52
+ ...makeEvents('c1', 'page_view', ['2025-01-16']),
53
+ ]
54
+
55
+ const result = RfmEngine.aggregateFromEvents(contacts, events, opts)
56
+ expect(result).toHaveLength(1)
57
+ expect(result[0].event_count).toBe(1)
58
+ })
59
+
60
+ it('excludes events outside time window', () => {
61
+ const events: RfmEventRow[] = [
62
+ ...makeEvents('c1', 'purchase', ['2024-12-31']), // before from
63
+ ...makeEvents('c1', 'purchase', ['2025-03-02']), // after to
64
+ ...makeEvents('c1', 'purchase', ['2025-02-01']), // within
65
+ ]
66
+
67
+ const result = RfmEngine.aggregateFromEvents(contacts, events, opts)
68
+ expect(result).toHaveLength(1)
69
+ expect(result[0].event_count).toBe(1)
70
+ })
71
+
72
+ it('resolves contact via reachy_id when contact_id is missing', () => {
73
+ const events: RfmEventRow[] = [
74
+ { reachy_id: 'r1', event_name: 'purchase', event_timestamp: '2025-02-01' },
75
+ ]
76
+
77
+ const result = RfmEngine.aggregateFromEvents(contacts, events, opts)
78
+ expect(result).toHaveLength(1)
79
+ expect(result[0].contact_id).toBe('c1')
80
+ })
81
+
82
+ it('ignores events with no resolvable contact', () => {
83
+ const events: RfmEventRow[] = [
84
+ { contact_id: 'nonexistent', event_name: 'purchase', event_timestamp: '2025-02-01' },
85
+ ]
86
+
87
+ const result = RfmEngine.aggregateFromEvents(contacts, events, opts)
88
+ expect(result).toHaveLength(0)
89
+ })
90
+
91
+ it('filters by filterContactIds when provided', () => {
92
+ const events: RfmEventRow[] = [
93
+ ...makeEvents('c1', 'purchase', ['2025-02-01']),
94
+ ...makeEvents('c2', 'purchase', ['2025-02-01']),
95
+ ]
96
+
97
+ const result = RfmEngine.aggregateFromEvents(contacts, events, {
98
+ ...opts,
99
+ filterContactIds: new Set(['c1']),
100
+ })
101
+ expect(result).toHaveLength(1)
102
+ expect(result[0].contact_id).toBe('c1')
103
+ })
104
+
105
+ it('returns empty array for empty events', () => {
106
+ const result = RfmEngine.aggregateFromEvents(contacts, [], opts)
107
+ expect(result).toHaveLength(0)
108
+ })
109
+
110
+ it('returns empty array for empty contacts', () => {
111
+ const events = makeEvents('c1', 'purchase', ['2025-02-01'])
112
+ const result = RfmEngine.aggregateFromEvents([], events, opts)
113
+ expect(result).toHaveLength(0)
114
+ })
115
+ })
116
+
117
+ describe('scoreAggregates', () => {
118
+ const contacts = makeContacts(5)
119
+
120
+ it('assigns R and F scores 1-5 based on percentiles', () => {
121
+ const aggregates = [
122
+ { contact_id: 'c1', last_ts: new Date('2025-02-28'), event_count: 50 }, // very recent, high freq
123
+ { contact_id: 'c2', last_ts: new Date('2025-02-20'), event_count: 30 },
124
+ { contact_id: 'c3', last_ts: new Date('2025-02-01'), event_count: 10 },
125
+ { contact_id: 'c4', last_ts: new Date('2025-01-15'), event_count: 5 },
126
+ { contact_id: 'c5', last_ts: new Date('2025-01-01'), event_count: 1 }, // oldest, lowest freq
127
+ ]
128
+
129
+ const { scored, r_days_cuts, f_count_cuts } = RfmEngine.scoreAggregates(contacts, aggregates, { to: NOW })
130
+
131
+ expect(scored).toHaveLength(5)
132
+ expect(r_days_cuts).toHaveLength(4)
133
+ expect(f_count_cuts).toHaveLength(4)
134
+
135
+ // Most recent contact should have highest R score
136
+ const c1 = scored.find(s => s.contact_id === 'c1')!
137
+ expect(c1.r_score).toBe(5)
138
+ expect(c1.f_score).toBe(5)
139
+
140
+ // Oldest with lowest freq should have lowest scores
141
+ const c5 = scored.find(s => s.contact_id === 'c5')!
142
+ expect(c5.r_score).toBe(1)
143
+ expect(c5.f_score).toBe(1)
144
+
145
+ // All should have a valid segment key
146
+ for (const s of scored) {
147
+ expect(s.segment_key).toBeTruthy()
148
+ expect([1, 2, 3, 4, 5]).toContain(s.r_score)
149
+ expect([1, 2, 3, 4, 5]).toContain(s.f_score)
150
+ }
151
+ })
152
+
153
+ it('computes segment_key as "new_users" for single high-recency low-freq contact', () => {
154
+ // With only 1 contact, percentile puts it at R=5, F=1 → new_users
155
+ const aggregates = [
156
+ { contact_id: 'c1', last_ts: new Date('2025-02-28'), event_count: 100 },
157
+ ]
158
+ const { scored } = RfmEngine.scoreAggregates(contacts, aggregates, { to: NOW })
159
+ // Single element: R=5 (most recent), F=1 (single bucket) → R=5,F=1 → new_users
160
+ expect(scored[0].r_score).toBe(5)
161
+ expect(scored[0].f_score).toBe(1)
162
+ expect(scored[0].segment_key).toBe('new_users')
163
+ })
164
+
165
+ it('determines email_ok and sms_ok from contact data', () => {
166
+ const contactsWithPrefs: RfmContactRow[] = [
167
+ { id: 'c1', email: 'a@b.com', phone: '+5511999', is_subscribed: true },
168
+ { id: 'c2', email: '', phone: '', is_subscribed: false },
169
+ ]
170
+ const aggregates = [
171
+ { contact_id: 'c1', last_ts: new Date('2025-02-28'), event_count: 10 },
172
+ { contact_id: 'c2', last_ts: new Date('2025-02-28'), event_count: 5 },
173
+ ]
174
+ const { scored } = RfmEngine.scoreAggregates(contactsWithPrefs, aggregates, { to: NOW })
175
+
176
+ const c1 = scored.find(s => s.contact_id === 'c1')!
177
+ expect(c1.email_ok).toBe(1)
178
+ expect(c1.sms_ok).toBe(1)
179
+
180
+ const c2 = scored.find(s => s.contact_id === 'c2')!
181
+ expect(c2.email_ok).toBe(0) // is_subscribed=false
182
+ expect(c2.sms_ok).toBe(0)
183
+ })
184
+
185
+ it('skips aggregates for contacts not in contact list', () => {
186
+ const aggregates = [
187
+ { contact_id: 'c1', last_ts: new Date('2025-02-28'), event_count: 10 },
188
+ { contact_id: 'nonexistent', last_ts: new Date('2025-02-28'), event_count: 5 },
189
+ ]
190
+ const { scored } = RfmEngine.scoreAggregates(contacts, aggregates, { to: NOW })
191
+ expect(scored).toHaveLength(1)
192
+ })
193
+ })
194
+
195
+ describe('thresholdsFromScored', () => {
196
+ it('extracts min/max per R and F score', () => {
197
+ const scored: RfmScoredContact[] = [
198
+ { contact_id: 'c1', last_ts: new Date(), event_count: 50, recency_days: 1, r_score: 5, f_score: 5, segment_key: 'champions', email_ok: 1, sms_ok: 1 },
199
+ { contact_id: 'c2', last_ts: new Date(), event_count: 30, recency_days: 3, r_score: 5, f_score: 4, segment_key: 'champions', email_ok: 1, sms_ok: 1 },
200
+ { contact_id: 'c3', last_ts: new Date(), event_count: 1, recency_days: 90, r_score: 1, f_score: 1, segment_key: 'hibernating', email_ok: 1, sms_ok: 0 },
201
+ ]
202
+
203
+ const thresholds = RfmEngine.thresholdsFromScored(scored)
204
+
205
+ expect(thresholds.length).toBeGreaterThan(0)
206
+
207
+ // Sorted by kind (f before r), then by score
208
+ const first = thresholds[0]
209
+ expect(first.kind).toBe('f')
210
+
211
+ // R score 5 should have min=1, max=3
212
+ const r5 = thresholds.find(t => t.kind === 'r' && t.score === 5)
213
+ expect(r5).toBeDefined()
214
+ expect(r5!.min_value).toBe(1)
215
+ expect(r5!.max_value).toBe(3)
216
+ })
217
+
218
+ it('returns empty array for empty scored', () => {
219
+ expect(RfmEngine.thresholdsFromScored([])).toEqual([])
220
+ })
221
+ })
222
+
223
+ describe('previewFromScored', () => {
224
+ it('returns all 10 segments with counts and percentages', () => {
225
+ const scored: RfmScoredContact[] = [
226
+ { contact_id: 'c1', last_ts: new Date(), event_count: 50, recency_days: 1, r_score: 5, f_score: 5, segment_key: 'champions', email_ok: 1, sms_ok: 1 },
227
+ { contact_id: 'c2', last_ts: new Date(), event_count: 1, recency_days: 90, r_score: 1, f_score: 1, segment_key: 'hibernating', email_ok: 0, sms_ok: 0 },
228
+ ]
229
+
230
+ const preview = RfmEngine.previewFromScored(scored)
231
+ expect(preview).toHaveLength(10)
232
+
233
+ const champions = preview.find(p => p.segment_key === 'champions')!
234
+ expect(champions.users).toBe(1)
235
+ expect(champions.percent).toBe(50)
236
+ expect(champions.email_reachability).toBe(1)
237
+
238
+ const hibernating = preview.find(p => p.segment_key === 'hibernating')!
239
+ expect(hibernating.users).toBe(1)
240
+ expect(hibernating.email_reachability).toBe(0)
241
+
242
+ // All other segments should have 0 users
243
+ const empties = preview.filter(p => !['champions', 'hibernating'].includes(p.segment_key))
244
+ for (const seg of empties) {
245
+ expect(seg.users).toBe(0)
246
+ expect(seg.percent).toBe(0)
247
+ }
248
+ })
249
+
250
+ it('returns all zeros for empty scored', () => {
251
+ const preview = RfmEngine.previewFromScored([])
252
+ expect(preview).toHaveLength(10)
253
+ for (const seg of preview) {
254
+ expect(seg.users).toBe(0)
255
+ expect(seg.percent).toBe(0)
256
+ }
257
+ })
258
+ })
259
+
260
+ describe('computeFromEvents', () => {
261
+ it('orchestrates aggregate → score → thresholds → preview', () => {
262
+ const contacts = makeContacts(3)
263
+ const events: RfmEventRow[] = [
264
+ ...makeEvents('c1', 'purchase', ['2025-02-28', '2025-02-25', '2025-02-20']),
265
+ ...makeEvents('c2', 'purchase', ['2025-01-15']),
266
+ ...makeEvents('c3', 'purchase', ['2025-01-01', '2025-01-02']),
267
+ ]
268
+ const opts: RfmComputeOptions = {
269
+ from: '2025-01-01',
270
+ to: '2025-03-01',
271
+ rfEventName: 'purchase',
272
+ }
273
+
274
+ const { segments, thresholds, scored } = RfmEngine.computeFromEvents(contacts, events, opts)
275
+
276
+ expect(scored).toHaveLength(3)
277
+ expect(segments).toHaveLength(10)
278
+ expect(thresholds.length).toBeGreaterThan(0)
279
+
280
+ const totalUsers = segments.reduce((sum, s) => sum + s.users, 0)
281
+ expect(totalUsers).toBe(3)
282
+ })
283
+ })
284
+ })
@@ -0,0 +1,210 @@
1
+ import { RfmSegmentBuilder, RfmThresholdRowLike } from '../builders/RfmSegmentBuilder'
2
+
3
+ describe('RfmSegmentBuilder', () => {
4
+ describe('getSegmentScoreRanges', () => {
5
+ const expected: Record<string, { r: { minScore: number; maxScore: number }; f: { minScore: number; maxScore: number } }> = {
6
+ champions: { r: { minScore: 4, maxScore: 5 }, f: { minScore: 4, maxScore: 5 } },
7
+ loyal_users: { r: { minScore: 3, maxScore: 3 }, f: { minScore: 4, maxScore: 5 } },
8
+ potential_loyalists: { r: { minScore: 4, maxScore: 5 }, f: { minScore: 2, maxScore: 3 } },
9
+ new_users: { r: { minScore: 5, maxScore: 5 }, f: { minScore: 1, maxScore: 1 } },
10
+ promising: { r: { minScore: 4, maxScore: 4 }, f: { minScore: 1, maxScore: 1 } },
11
+ needing_attention: { r: { minScore: 3, maxScore: 3 }, f: { minScore: 3, maxScore: 3 } },
12
+ about_to_sleep: { r: { minScore: 3, maxScore: 3 }, f: { minScore: 1, maxScore: 2 } },
13
+ cannot_lose_them: { r: { minScore: 1, maxScore: 2 }, f: { minScore: 5, maxScore: 5 } },
14
+ at_risk: { r: { minScore: 1, maxScore: 2 }, f: { minScore: 3, maxScore: 4 } },
15
+ hibernating: { r: { minScore: 1, maxScore: 2 }, f: { minScore: 1, maxScore: 2 } },
16
+ }
17
+
18
+ it.each(Object.entries(expected))('returns correct ranges for "%s"', (key, ranges) => {
19
+ expect(RfmSegmentBuilder.getSegmentScoreRanges(key)).toEqual(ranges)
20
+ })
21
+
22
+ it('returns null for unknown segment key', () => {
23
+ expect(RfmSegmentBuilder.getSegmentScoreRanges('unknown')).toBeNull()
24
+ })
25
+
26
+ it('returns null for empty string', () => {
27
+ expect(RfmSegmentBuilder.getSegmentScoreRanges('')).toBeNull()
28
+ })
29
+
30
+ it('handles whitespace in key', () => {
31
+ expect(RfmSegmentBuilder.getSegmentScoreRanges(' champions ')).toEqual(expected.champions)
32
+ })
33
+ })
34
+
35
+ describe('getSegmentName', () => {
36
+ it('returns "Champions" for "champions"', () => {
37
+ expect(RfmSegmentBuilder.getSegmentName('champions')).toBe('Champions')
38
+ })
39
+
40
+ it('returns "At Risk" for "at_risk"', () => {
41
+ expect(RfmSegmentBuilder.getSegmentName('at_risk')).toBe('At Risk')
42
+ })
43
+
44
+ it('returns the key itself for unknown segment', () => {
45
+ expect(RfmSegmentBuilder.getSegmentName('custom_segment')).toBe('custom_segment')
46
+ })
47
+
48
+ it('returns "Segment" for empty/null input', () => {
49
+ expect(RfmSegmentBuilder.getSegmentName('')).toBe('Segment')
50
+ expect(RfmSegmentBuilder.getSegmentName(null as any)).toBe('Segment')
51
+ })
52
+ })
53
+
54
+ describe('clampInt', () => {
55
+ it('clamps within range', () => {
56
+ expect(RfmSegmentBuilder.clampInt(5, 1, 10)).toBe(5)
57
+ })
58
+
59
+ it('clamps below min', () => {
60
+ expect(RfmSegmentBuilder.clampInt(-1, 0, 10)).toBe(0)
61
+ })
62
+
63
+ it('clamps above max', () => {
64
+ expect(RfmSegmentBuilder.clampInt(100, 0, 10)).toBe(10)
65
+ })
66
+
67
+ it('truncates decimals', () => {
68
+ expect(RfmSegmentBuilder.clampInt(5.9, 1, 10)).toBe(5)
69
+ })
70
+
71
+ it('returns min for NaN', () => {
72
+ expect(RfmSegmentBuilder.clampInt(NaN, 1, 10)).toBe(1)
73
+ })
74
+
75
+ it('returns min for Infinity', () => {
76
+ expect(RfmSegmentBuilder.clampInt(Infinity, 1, 10)).toBe(1)
77
+ })
78
+ })
79
+
80
+ describe('buildDefinition', () => {
81
+ const thresholds: RfmThresholdRowLike[] = [
82
+ // Recency thresholds (score → max_days)
83
+ { kind: 'r', score: 5, min_value: 0, max_value: 7 },
84
+ { kind: 'r', score: 4, min_value: 8, max_value: 14 },
85
+ { kind: 'r', score: 3, min_value: 15, max_value: 30 },
86
+ { kind: 'r', score: 2, min_value: 31, max_value: 60 },
87
+ { kind: 'r', score: 1, min_value: 61, max_value: 90 },
88
+ // Frequency thresholds (score → min/max count)
89
+ { kind: 'f', score: 1, min_value: 1, max_value: 2 },
90
+ { kind: 'f', score: 2, min_value: 3, max_value: 5 },
91
+ { kind: 'f', score: 3, min_value: 6, max_value: 10 },
92
+ { kind: 'f', score: 4, min_value: 11, max_value: 20 },
93
+ { kind: 'f', score: 5, min_value: 21, max_value: 50 },
94
+ ]
95
+
96
+ it('builds a valid definition for champions', () => {
97
+ const def = RfmSegmentBuilder.buildDefinition({
98
+ rfEventName: 'purchase',
99
+ windowDays: 90,
100
+ segmentKey: 'champions',
101
+ thresholds,
102
+ })
103
+
104
+ expect(def.segment_key).toBe('champions')
105
+ expect(def.segment_name).toBe('Champions')
106
+ expect(def.window_days).toBe(90)
107
+ expect(def.criteria).toBeDefined()
108
+ expect(def.criteria.type).toBe('past-behavior')
109
+ expect(def.criteria.groups).toBeDefined()
110
+ expect(def.criteria.groups.length).toBeGreaterThanOrEqual(1)
111
+
112
+ // Frequency: score 4-5 → op >= minCount
113
+ expect(def.frequency.op).toBe('>=')
114
+ expect(def.frequency.value).toBeGreaterThanOrEqual(1)
115
+ })
116
+
117
+ it('builds a valid definition for hibernating (low R, low F)', () => {
118
+ const def = RfmSegmentBuilder.buildDefinition({
119
+ rfEventName: 'purchase',
120
+ windowDays: 90,
121
+ segmentKey: 'hibernating',
122
+ thresholds,
123
+ })
124
+
125
+ expect(def.segment_key).toBe('hibernating')
126
+ expect(def.frequency.op).toBe('<=')
127
+ })
128
+
129
+ it('builds a definition with between frequency for mid-range segments', () => {
130
+ const def = RfmSegmentBuilder.buildDefinition({
131
+ rfEventName: 'purchase',
132
+ windowDays: 90,
133
+ segmentKey: 'at_risk',
134
+ thresholds,
135
+ })
136
+
137
+ expect(def.frequency.op).toBe('between')
138
+ expect(def.frequency.value2).not.toBeNull()
139
+ })
140
+
141
+ it('throws for invalid segment key', () => {
142
+ expect(() => RfmSegmentBuilder.buildDefinition({
143
+ rfEventName: 'purchase',
144
+ windowDays: 90,
145
+ segmentKey: 'nonexistent',
146
+ thresholds,
147
+ })).toThrow('segment_key inválido')
148
+ })
149
+
150
+ it('throws when no recency thresholds match the range', () => {
151
+ expect(() => RfmSegmentBuilder.buildDefinition({
152
+ rfEventName: 'purchase',
153
+ windowDays: 90,
154
+ segmentKey: 'champions',
155
+ thresholds: thresholds.filter(t => t.kind === 'f'), // no recency
156
+ })).toThrow()
157
+ })
158
+
159
+ it('throws when no frequency thresholds match the range', () => {
160
+ expect(() => RfmSegmentBuilder.buildDefinition({
161
+ rfEventName: 'purchase',
162
+ windowDays: 90,
163
+ segmentKey: 'champions',
164
+ thresholds: thresholds.filter(t => t.kind === 'r'), // no frequency
165
+ })).toThrow()
166
+ })
167
+
168
+ it('includes recency negate rule when rMax < 5', () => {
169
+ const def = RfmSegmentBuilder.buildDefinition({
170
+ rfEventName: 'purchase',
171
+ windowDays: 90,
172
+ segmentKey: 'hibernating', // r: 1-2
173
+ thresholds,
174
+ })
175
+
176
+ const rfmGroup = def.criteria.groups[0]
177
+ const negateRule = rfmGroup.rules.find((r: any) => r.negate === true)
178
+ expect(negateRule).toBeDefined()
179
+ })
180
+
181
+ it('does not include negate rule when rMax >= 5', () => {
182
+ const def = RfmSegmentBuilder.buildDefinition({
183
+ rfEventName: 'purchase',
184
+ windowDays: 90,
185
+ segmentKey: 'champions', // r: 4-5
186
+ thresholds,
187
+ })
188
+
189
+ const rfmGroup = def.criteria.groups[0]
190
+ const negateRule = rfmGroup.rules.find((r: any) => r.negate === true)
191
+ expect(negateRule).toBeUndefined()
192
+ })
193
+
194
+ it('merges filterCriteria groups when provided', () => {
195
+ const filterCriteria = {
196
+ groups: [{ operator: 'AND', rules: [{ kind: 'property', field: 'is_subscribed', op: 'equals', value: true }] }]
197
+ }
198
+ const def = RfmSegmentBuilder.buildDefinition({
199
+ rfEventName: 'purchase',
200
+ windowDays: 90,
201
+ segmentKey: 'champions',
202
+ thresholds,
203
+ filterCriteria,
204
+ })
205
+
206
+ // Should have the filter group + rfm group
207
+ expect(def.criteria.groups.length).toBe(2)
208
+ })
209
+ })
210
+ })