import { RfmSegmentBuilder, RfmThresholdRowLike } from '../builders/RfmSegmentBuilder' describe('RfmSegmentBuilder', () => { describe('getSegmentScoreRanges', () => { const expected: Record = { champions: { r: { minScore: 4, maxScore: 5 }, f: { minScore: 4, maxScore: 5 } }, loyal_users: { r: { minScore: 3, maxScore: 3 }, f: { minScore: 4, maxScore: 5 } }, potential_loyalists: { r: { minScore: 4, maxScore: 5 }, f: { minScore: 2, maxScore: 3 } }, new_users: { r: { minScore: 5, maxScore: 5 }, f: { minScore: 1, maxScore: 1 } }, promising: { r: { minScore: 4, maxScore: 4 }, f: { minScore: 1, maxScore: 1 } }, needing_attention: { r: { minScore: 3, maxScore: 3 }, f: { minScore: 3, maxScore: 3 } }, about_to_sleep: { r: { minScore: 3, maxScore: 3 }, f: { minScore: 1, maxScore: 2 } }, cannot_lose_them: { r: { minScore: 1, maxScore: 2 }, f: { minScore: 5, maxScore: 5 } }, at_risk: { r: { minScore: 1, maxScore: 2 }, f: { minScore: 3, maxScore: 4 } }, hibernating: { r: { minScore: 1, maxScore: 2 }, f: { minScore: 1, maxScore: 2 } }, } it.each(Object.entries(expected))('returns correct ranges for "%s"', (key, ranges) => { expect(RfmSegmentBuilder.getSegmentScoreRanges(key)).toEqual(ranges) }) it('returns null for unknown segment key', () => { expect(RfmSegmentBuilder.getSegmentScoreRanges('unknown')).toBeNull() }) it('returns null for empty string', () => { expect(RfmSegmentBuilder.getSegmentScoreRanges('')).toBeNull() }) it('handles whitespace in key', () => { expect(RfmSegmentBuilder.getSegmentScoreRanges(' champions ')).toEqual(expected.champions) }) }) describe('getSegmentName', () => { it('returns "Champions" for "champions"', () => { expect(RfmSegmentBuilder.getSegmentName('champions')).toBe('Champions') }) it('returns "At Risk" for "at_risk"', () => { expect(RfmSegmentBuilder.getSegmentName('at_risk')).toBe('At Risk') }) it('returns the key itself for unknown segment', () => { expect(RfmSegmentBuilder.getSegmentName('custom_segment')).toBe('custom_segment') }) it('returns "Segment" for empty/null input', () => { expect(RfmSegmentBuilder.getSegmentName('')).toBe('Segment') expect(RfmSegmentBuilder.getSegmentName(null as any)).toBe('Segment') }) }) describe('clampInt', () => { it('clamps within range', () => { expect(RfmSegmentBuilder.clampInt(5, 1, 10)).toBe(5) }) it('clamps below min', () => { expect(RfmSegmentBuilder.clampInt(-1, 0, 10)).toBe(0) }) it('clamps above max', () => { expect(RfmSegmentBuilder.clampInt(100, 0, 10)).toBe(10) }) it('truncates decimals', () => { expect(RfmSegmentBuilder.clampInt(5.9, 1, 10)).toBe(5) }) it('returns min for NaN', () => { expect(RfmSegmentBuilder.clampInt(NaN, 1, 10)).toBe(1) }) it('returns min for Infinity', () => { expect(RfmSegmentBuilder.clampInt(Infinity, 1, 10)).toBe(1) }) }) describe('buildDefinition', () => { const thresholds: RfmThresholdRowLike[] = [ // Recency thresholds (score → max_days) { kind: 'r', score: 5, min_value: 0, max_value: 7 }, { kind: 'r', score: 4, min_value: 8, max_value: 14 }, { kind: 'r', score: 3, min_value: 15, max_value: 30 }, { kind: 'r', score: 2, min_value: 31, max_value: 60 }, { kind: 'r', score: 1, min_value: 61, max_value: 90 }, // Frequency thresholds (score → min/max count) { kind: 'f', score: 1, min_value: 1, max_value: 2 }, { kind: 'f', score: 2, min_value: 3, max_value: 5 }, { kind: 'f', score: 3, min_value: 6, max_value: 10 }, { kind: 'f', score: 4, min_value: 11, max_value: 20 }, { kind: 'f', score: 5, min_value: 21, max_value: 50 }, ] it('builds a valid definition for champions', () => { const def = RfmSegmentBuilder.buildDefinition({ rfEventName: 'purchase', windowDays: 90, segmentKey: 'champions', thresholds, }) expect(def.segment_key).toBe('champions') expect(def.segment_name).toBe('Champions') expect(def.window_days).toBe(90) expect(def.criteria).toBeDefined() expect(def.criteria.type).toBe('past-behavior') expect(def.criteria.groups).toBeDefined() expect(def.criteria.groups.length).toBeGreaterThanOrEqual(1) // Frequency: score 4-5 → op >= minCount expect(def.frequency.op).toBe('>=') expect(def.frequency.value).toBeGreaterThanOrEqual(1) }) it('builds a valid definition for hibernating (low R, low F)', () => { const def = RfmSegmentBuilder.buildDefinition({ rfEventName: 'purchase', windowDays: 90, segmentKey: 'hibernating', thresholds, }) expect(def.segment_key).toBe('hibernating') expect(def.frequency.op).toBe('<=') }) it('builds a definition with between frequency for mid-range segments', () => { const def = RfmSegmentBuilder.buildDefinition({ rfEventName: 'purchase', windowDays: 90, segmentKey: 'at_risk', thresholds, }) expect(def.frequency.op).toBe('between') expect(def.frequency.value2).not.toBeNull() }) it('throws for invalid segment key', () => { expect(() => RfmSegmentBuilder.buildDefinition({ rfEventName: 'purchase', windowDays: 90, segmentKey: 'nonexistent', thresholds, })).toThrow('segment_key inválido') }) it('throws when no recency thresholds match the range', () => { expect(() => RfmSegmentBuilder.buildDefinition({ rfEventName: 'purchase', windowDays: 90, segmentKey: 'champions', thresholds: thresholds.filter(t => t.kind === 'f'), // no recency })).toThrow() }) it('throws when no frequency thresholds match the range', () => { expect(() => RfmSegmentBuilder.buildDefinition({ rfEventName: 'purchase', windowDays: 90, segmentKey: 'champions', thresholds: thresholds.filter(t => t.kind === 'r'), // no frequency })).toThrow() }) it('includes recency negate rule when rMax < 5', () => { const def = RfmSegmentBuilder.buildDefinition({ rfEventName: 'purchase', windowDays: 90, segmentKey: 'hibernating', // r: 1-2 thresholds, }) const rfmGroup = def.criteria.groups[0] const negateRule = rfmGroup.rules.find((r: any) => r.negate === true) expect(negateRule).toBeDefined() }) it('does not include negate rule when rMax >= 5', () => { const def = RfmSegmentBuilder.buildDefinition({ rfEventName: 'purchase', windowDays: 90, segmentKey: 'champions', // r: 4-5 thresholds, }) const rfmGroup = def.criteria.groups[0] const negateRule = rfmGroup.rules.find((r: any) => r.negate === true) expect(negateRule).toBeUndefined() }) it('merges filterCriteria groups when provided', () => { const filterCriteria = { groups: [{ operator: 'AND', rules: [{ kind: 'property', field: 'is_subscribed', op: 'equals', value: true }] }] } const def = RfmSegmentBuilder.buildDefinition({ rfEventName: 'purchase', windowDays: 90, segmentKey: 'champions', thresholds, filterCriteria, }) // Should have the filter group + rfm group expect(def.criteria.groups.length).toBe(2) }) }) })