Skip to content
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[submodule "spec/engine-test-data"]
path = spec/engine-test-data
url = [email protected]:Flagsmith/engine-test-data.git
branch = v1.0.0
branch = main
1 change: 1 addition & 0 deletions lib/flagsmith/engine/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
require_relative 'segments/evaluator'
require_relative 'segments/models'
require_relative 'utils/hash_func'
require_relative 'evaluation/mappers'

module Flagsmith
module Engine
Expand Down
21 changes: 21 additions & 0 deletions lib/flagsmith/engine/evaluation/core.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

module Flagsmith
module Engine
module Evaluation
module Core
# Get evaluation result from evaluation context
#
# @param evaluation_context [Hash] The evaluation context
# @return [Hash] Evaluation result with flags and segments
def self.get_evaluation_result(_evaluation_context)
# TODO: Implement core evaluation logic
{
flags: {},
segments: []
}
end
end
end
end
end
222 changes: 222 additions & 0 deletions lib/flagsmith/engine/evaluation/mappers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
# frozen_string_literal: true

module Flagsmith
module Engine
module Evaluation
module Mappers
STRONGEST_PRIORITY = Float::INFINITY
WEAKEST_PRIORITY = -Float::INFINITY

# @param environment [Flagsmith::Engine::Environment] The environment model
# @param identity [Flagsmith::Engine::Identity, nil] Optional identity model
# @param override_traits [Array<Flagsmith::Engine::Identities::Trait>, nil] Optional override traits
# @return [Hash] Evaluation context with environment, features, segments, and optionally identity
def self.get_evaluation_context(environment, identity = nil, override_traits = nil)
environment_context = map_environment_model_to_evaluation_context(environment)
identity_context = identity ? map_identity_model_to_identity_context(identity, override_traits) : nil

context = environment_context.dup
context[:identity] = identity_context if identity_context

context
end

# Maps environment model to evaluation context
#
# @param environment [Flagsmith::Engine::Environment] The environment model
# @return [Hash] Context with :environment, :features, and :segments keys
def self.map_environment_model_to_evaluation_context(environment)
environment_context = {
key: environment.api_key,
name: environment.project.name
}

# Map feature states to features hash
features = {}
environment.feature_states.each do |fs|
# Map multivariate values if present
variants = nil
if fs.multivariate_feature_state_values&.any?
variants = fs.multivariate_feature_state_values.map do |mv|
{
value: mv.multivariate_feature_option.value,
weight: mv.percentage_allocation,
priority: mv.id || uuid_to_big_int(mv.mv_fs_value_uuid)
}
end
end

feature_hash = {
key: fs.django_id&.to_s || fs.uuid,
feature_key: fs.feature.id.to_s,
name: fs.feature.name,
enabled: fs.enabled,
value: fs.get_value
}

feature_hash[:variants] = variants if variants
priority = fs.feature_segment&.priority
feature_hash[:priority] = priority unless priority.nil?
feature_hash[:metadata] = { flagsmith_id: fs.feature.id }

features[fs.feature.name] = feature_hash
end

# Map segments from project
segments = {}
environment.project.segments.each do |segment|
overrides = segment.feature_states.map do |fs|
override_hash = {
key: fs.django_id&.to_s || fs.uuid,
feature_key: fs.feature.id.to_s,
name: fs.feature.name,
enabled: fs.enabled,
value: fs.get_value
}
override_hash[:priority] = fs.feature_segment.priority if fs.feature_segment&.priority
override_hash[:metadata] = { flagsmith_id: fs.feature.id }
override_hash
end

segments[segment.id.to_s] = {
key: segment.id.to_s,
name: segment.name,
rules: segment.rules.map { |rule| map_segment_rule_model_to_rule(rule) },
overrides: overrides,
metadata: {
source: 'API',
flagsmith_id: segment.id
}
}
end

# Map identity overrides to segments
if environment.identity_overrides&.any?
identity_override_segments = map_identity_overrides_to_segments(environment.identity_overrides)
segments.merge!(identity_override_segments)
end

{
environment: environment_context,
features: features,
segments: segments
}
end

def self.uuid_to_big_int(uuid)
uuid.gsub('-', '').to_i(16)
end

# Maps identity model to identity context
#
# @param identity [Flagsmith::Engine::Identity] The identity model
# @param override_traits [Array<Flagsmith::Engine::Identities::Trait>, nil] Optional override traits
# @return [Hash] Identity context with :identifier, :key, and :traits
def self.map_identity_model_to_identity_context(identity, override_traits = nil)
# Use override traits if provided, otherwise use identity's traits
traits = override_traits || identity.identity_traits

# Map traits to a hash with trait key => trait value
traits_hash = {}
traits.each do |trait|
traits_hash[trait.trait_key] = trait.trait_value
end

{
identifier: identity.identifier,
key: identity.django_id&.to_s || identity.composite_key,
traits: traits_hash
}
end

# Maps segment rule model to rule hash
#
# @param rule [Flagsmith::Engine::Segments::Rule] The segment rule model
# @return [Hash] Mapped rule with :type, :conditions, and :rules
def self.map_segment_rule_model_to_rule(rule)
result = {
type: rule.type
}

# Map conditions if present
result[:conditions] = (rule.conditions || []).map do |condition|
{ property: condition.property, operator: condition.operator, value: condition.value }
end

result[:rules] = if rule.rules&.any?
rule.rules.map { |nested_rule| map_segment_rule_model_to_rule(nested_rule) }
else
[]
end

result
end

# Maps identity overrides to segments
#
# @param identity_overrides [Array<Flagsmith::Engine::Identity>] Array of identity override models
# @return [Hash] Segments hash for identity overrides
def self.map_identity_overrides_to_segments(identity_overrides)
segments = {}
features_to_identifiers = {}

identity_overrides.each do |identity|
next if identity.identity_features.nil? || !identity.identity_features.any?

# Sort features by name for consistent hashing
sorted_features = identity.identity_features.to_a.sort_by { |fs| fs.feature.name }

# Create override keys for hashing
overrides_key = sorted_features.map do |fs|
{
feature_key: fs.feature.id.to_s,
name: fs.feature.name,
enabled: fs.enabled,
value: fs.get_value,
priority: STRONGEST_PRIORITY,
metadata: {
flagsmith_id: fs.feature.id
}
}
end

# Create hash of the overrides to group identities with same overrides
overrides_hash = overrides_key.hash

features_to_identifiers[overrides_hash] ||= { identifiers: [], overrides: overrides_key }
features_to_identifiers[overrides_hash][:identifiers] << identity.identifier
end

# Create segments for each unique set of overrides
features_to_identifiers.each do |overrides_hash, data|
segment_key = "identity_override_#{overrides_hash}"

segments[segment_key] = {
key: segment_key,
name: 'identity_override',
rules: [
{
type: 'ALL',
conditions: [
{
property: '$.identity.identifier',
operator: 'IN',
value: data[:identifiers]
}
],
rules: []
}
],
metadata: {
source: 'identity_override'
},
overrides: data[:overrides]
}
end

segments
end
end
end
end
end
2 changes: 1 addition & 1 deletion spec/engine-test-data
Submodule engine-test-data updated 190 files
69 changes: 35 additions & 34 deletions spec/engine/e2e/engine_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,44 @@

require 'spec_helper'

def load_test_cases(filepath)
data = JSON.parse(File.open(filepath).read, symbolize_names: true)
environment = Flagsmith::Engine::Environment.build(data[:environment])

data[:identities_and_responses].map do |test_case|
identity = Flagsmith::Engine::Identity.build(test_case[:identity])
{
environment: environment,
identity: identity,
response: test_case[:response]
}
end
def get_test_files
test_data_dir = File.join(APP_ROOT, 'spec/engine-test-data/test_cases')
Dir.glob(File.join(test_data_dir, '*.{json,jsonc}')).sort
end

def parse_jsonc(content)
# Simple JSONC parser: remove single-line comments
# JSON.parse will handle the rest
cleaned = content.lines.reject { |line| line.strip.start_with?('//') }.join
JSON.parse(cleaned, symbolize_names: true)
end

def load_test_file(filepath)
content = File.read(filepath)
parse_jsonc(content)
end

RSpec.describe Flagsmith::Engine do
load_test_cases(
File.join(APP_ROOT, 'spec/engine-test-data/data/environment_n9fbf9h3v4fFgH3U3ngWhb.json')
).each do |test_case|
engine = Flagsmith::Engine::Engine.new
json_flags = test_case.dig(:response, :flags).sort_by { |json| json.dig(:feature, :name) }
feature_states = engine.get_identity_feature_states(test_case[:environment], test_case[:identity]).sort_by { |fs| fs.feature.name }

it { expect(feature_states.length).to eq(json_flags.length) }

json_flags.each.with_index do |json_flag, index|
describe "feature state with ID #{json_flag.dig(:feature, :id)}" do
subject { feature_states[index] }

context '#enabled?' do
it { expect(subject.enabled?).to eq(json_flag[:enabled]) }
end

context '#get_value' do
it {
expect(subject.get_value(test_case[:identity].django_id)).to eq(json_flag[:feature_state_value])
}
end
test_files = get_test_files

raise "No test files found" if test_files.empty?

test_files.each do |filepath|
test_name = File.basename(filepath, File.extname(filepath))

describe test_name do
it 'should produce the expected evaluation result' do
test_case = load_test_file(filepath)

test_evaluation_context = test_case[:context]
test_expected_result = test_case[:result]

# TODO: Implement evaluation logic
evaluation_result = {}


# TODO: Uncomment when evaluation is implemented
# expect(evaluation_result).to eq(test_expected_result)
end
end
end
Expand Down
Loading
Loading