@@ -1384,9 +1384,8 @@ def setUp(self):
13841384 # Create config with holdouts
13851385 config_body_with_holdouts = self .config_dict_with_features .copy ()
13861386
1387- # Use correct feature flag IDs from the datafile
1388- boolean_feature_id = '91111' # boolean_single_variable_feature
1389- multi_variate_feature_id = '91114' # test_feature_in_experiment_and_rollout
1387+ # Rule IDs from the test config experiments
1388+ rule_id_1 = '111127' # test_experiment
13901389
13911390 config_body_with_holdouts ['holdouts' ] = [
13921391 {
@@ -1396,8 +1395,6 @@ def setUp(self):
13961395 'variations' : [],
13971396 'trafficAllocation' : [],
13981397 'audienceIds' : [],
1399- 'includedFlags' : [],
1400- 'excludedFlags' : [boolean_feature_id ]
14011398 },
14021399 {
14031400 'id' : 'holdout_2' ,
@@ -1406,8 +1403,7 @@ def setUp(self):
14061403 'variations' : [],
14071404 'trafficAllocation' : [],
14081405 'audienceIds' : [],
1409- 'includedFlags' : [multi_variate_feature_id ],
1410- 'excludedFlags' : []
1406+ 'includedRules' : [rule_id_1 ],
14111407 },
14121408 {
14131409 'id' : 'holdout_3' ,
@@ -1416,8 +1412,7 @@ def setUp(self):
14161412 'variations' : [],
14171413 'trafficAllocation' : [],
14181414 'audienceIds' : [],
1419- 'includedFlags' : [boolean_feature_id ],
1420- 'excludedFlags' : []
1415+ 'includedRules' : [rule_id_1 ],
14211416 }
14221417 ]
14231418
@@ -1435,24 +1430,20 @@ def test_get_global_holdouts__returns_global_holdouts(self):
14351430 """ Test that get_global_holdouts returns holdouts with includedRules=None. """
14361431
14371432 holdouts = self .config_with_holdouts .get_global_holdouts ()
1438- self .assertGreaterEqual ( len (holdouts ), 1 )
1433+ self .assertEqual ( 1 , len (holdouts ))
14391434
1440- global_holdout = next ((h for h in holdouts if h .key == 'global_holdout' ), None )
1441- self .assertIsNotNone (global_holdout )
1435+ global_holdout = holdouts [0 ]
14421436 self .assertEqual ('holdout_1' , global_holdout .id )
1437+ self .assertEqual ('global_holdout' , global_holdout .key )
14431438 self .assertTrue (global_holdout .is_global )
14441439
14451440 def test_get_holdouts_for_rule__returns_local_holdouts (self ):
14461441 """ Test that get_holdouts_for_rule returns holdouts targeting specific rules. """
14471442
1448- # Test with a rule that has a local holdout
1449- # First find a rule ID from experiments
1450- if self .config_with_holdouts .experiment_id_map :
1451- rule_id = list (self .config_with_holdouts .experiment_id_map .keys ())[0 ]
1452- # Won't assert on specific holdouts since test data varies
1453- holdouts = self .config_with_holdouts .get_holdouts_for_rule (rule_id )
1454- # Just verify it returns a list
1455- self .assertIsInstance (holdouts , list )
1443+ holdouts = self .config_with_holdouts .get_holdouts_for_rule ('111127' )
1444+ self .assertEqual (1 , len (holdouts ))
1445+ self .assertEqual ('specific_holdout' , holdouts [0 ].key )
1446+ self .assertFalse (holdouts [0 ].is_global )
14561447
14571448 def test_get_global_holdouts__caches_results (self ):
14581449 """ Test that get_global_holdouts returns consistent results. """
@@ -1466,10 +1457,7 @@ def test_get_global_holdouts__caches_results(self):
14661457 def test_get_holdouts_for_rule__empty_for_non_targeted_rules (self ):
14671458 """ Test that get_holdouts_for_rule returns empty list for non-targeted rules. """
14681459
1469- # Use a made-up rule ID that definitely doesn't exist
14701460 holdouts = self .config_with_holdouts .get_holdouts_for_rule ('definitely_non_existent_rule_id_12345' )
1471-
1472- # Should return empty list
14731461 self .assertEqual (0 , len (holdouts ))
14741462
14751463 def test_get_holdout__returns_holdout_for_valid_id (self ):
@@ -1519,12 +1507,16 @@ def test_holdout_initialization__categorizes_holdouts_properly(self):
15191507
15201508 self .assertIn ('holdout_1' , self .config_with_holdouts .holdout_id_map )
15211509 self .assertIn ('holdout_2' , self .config_with_holdouts .holdout_id_map )
1522- # Check if a holdout with ID 'holdout_1' exists in global_holdouts
1510+
1511+ # holdout_1 is global (no includedRules)
15231512 holdout_ids_in_global = [h .id for h in self .config_with_holdouts .global_holdouts ]
15241513 self .assertIn ('holdout_1' , holdout_ids_in_global )
1514+ self .assertNotIn ('holdout_2' , holdout_ids_in_global )
15251515
1526- # Verify rule-level mapping exists
1527- self .assertIsInstance (self .config_with_holdouts .rule_holdouts_map , dict )
1516+ # holdout_2 is local, mapped to rule '111127'
1517+ self .assertIn ('111127' , self .config_with_holdouts .rule_holdouts_map )
1518+ rule_holdout_ids = [h .id for h in self .config_with_holdouts .rule_holdouts_map ['111127' ]]
1519+ self .assertIn ('holdout_2' , rule_holdout_ids )
15281520
15291521 def test_holdout_initialization__only_processes_running_holdouts (self ):
15301522 """ Test that only running holdouts are processed during initialization. """
@@ -1536,6 +1528,215 @@ def test_holdout_initialization__only_processes_running_holdouts(self):
15361528 holdout_ids_in_global = [h .id for h in self .config_with_holdouts .global_holdouts ]
15371529 self .assertNotIn ('holdout_3' , holdout_ids_in_global )
15381530
1531+ def test_holdout_entity__is_global_property (self ):
1532+ """ Test Holdout.is_global property. """
1533+
1534+ # Global holdout (includedRules is None)
1535+ global_holdout = entities .Holdout (
1536+ id = '1' , key = 'global' , status = 'Running' ,
1537+ variations = [], trafficAllocation = [], audienceIds = [],
1538+ includedRules = None
1539+ )
1540+ self .assertTrue (global_holdout .is_global )
1541+
1542+ # Local holdout with rules
1543+ local_holdout = entities .Holdout (
1544+ id = '2' , key = 'local' , status = 'Running' ,
1545+ variations = [], trafficAllocation = [], audienceIds = [],
1546+ includedRules = ['rule1' , 'rule2' ]
1547+ )
1548+ self .assertFalse (local_holdout .is_global )
1549+
1550+ # Local holdout with empty array (not global)
1551+ empty_local_holdout = entities .Holdout (
1552+ id = '3' , key = 'empty_local' , status = 'Running' ,
1553+ variations = [], trafficAllocation = [], audienceIds = [],
1554+ includedRules = []
1555+ )
1556+ self .assertFalse (empty_local_holdout .is_global )
1557+
1558+ def test_holdout__none_vs_empty_array_distinction (self ):
1559+ """ Test that None (global) and [] (empty local) are handled differently. """
1560+
1561+ global_holdouts = self .config_with_holdouts .get_global_holdouts ()
1562+ self .assertEqual (1 , len (global_holdouts ))
1563+ self .assertIsNone (global_holdouts [0 ].includedRules )
1564+
1565+ # Empty local holdout should not appear in global list or rule maps
1566+ empty_holdout = entities .Holdout (
1567+ id = 'empty' , key = 'empty' , status = 'Running' ,
1568+ variations = [], trafficAllocation = [], audienceIds = [],
1569+ includedRules = []
1570+ )
1571+ self .assertFalse (empty_holdout .is_global )
1572+
1573+
1574+ class LocalHoldoutConfigTest (base .BaseTest ):
1575+ """ Tests for local holdout (rule-level targeting) config. """
1576+
1577+ def setUp (self ):
1578+ base .BaseTest .setUp (self )
1579+
1580+ config_dict = self .config_dict_with_features .copy ()
1581+
1582+ rule_id_1 = '111127' # test_experiment
1583+ rule_id_2 = '32222' # group_exp_1
1584+
1585+ config_dict ['holdouts' ] = [
1586+ {
1587+ 'id' : 'global_holdout_1' ,
1588+ 'key' : 'global_holdout' ,
1589+ 'status' : 'Running' ,
1590+ 'audienceIds' : [],
1591+ 'variations' : [
1592+ {'id' : 'global_var_1' , 'key' : 'global_control' , 'variables' : []}
1593+ ],
1594+ 'trafficAllocation' : [
1595+ {'entityId' : 'global_var_1' , 'endOfRange' : 5000 }
1596+ ]
1597+ },
1598+ {
1599+ 'id' : 'local_holdout_1' ,
1600+ 'key' : 'local_holdout_single' ,
1601+ 'status' : 'Running' ,
1602+ 'includedRules' : [rule_id_1 ],
1603+ 'audienceIds' : [],
1604+ 'variations' : [
1605+ {'id' : 'local_var_1' , 'key' : 'local_control' , 'variables' : []}
1606+ ],
1607+ 'trafficAllocation' : [
1608+ {'entityId' : 'local_var_1' , 'endOfRange' : 5000 }
1609+ ]
1610+ },
1611+ {
1612+ 'id' : 'local_holdout_2' ,
1613+ 'key' : 'local_holdout_multi' ,
1614+ 'status' : 'Running' ,
1615+ 'includedRules' : [rule_id_1 , rule_id_2 ],
1616+ 'audienceIds' : [],
1617+ 'variations' : [
1618+ {'id' : 'local_var_2' , 'key' : 'local_multi_control' , 'variables' : []}
1619+ ],
1620+ 'trafficAllocation' : [
1621+ {'entityId' : 'local_var_2' , 'endOfRange' : 5000 }
1622+ ]
1623+ },
1624+ {
1625+ 'id' : 'local_holdout_empty' ,
1626+ 'key' : 'local_holdout_empty' ,
1627+ 'status' : 'Running' ,
1628+ 'includedRules' : [],
1629+ 'audienceIds' : [],
1630+ 'variations' : [
1631+ {'id' : 'local_var_empty' , 'key' : 'empty_control' , 'variables' : []}
1632+ ],
1633+ 'trafficAllocation' : [
1634+ {'entityId' : 'local_var_empty' , 'endOfRange' : 10000 }
1635+ ]
1636+ },
1637+ {
1638+ 'id' : 'draft_holdout' ,
1639+ 'key' : 'draft_holdout' ,
1640+ 'status' : 'Draft' ,
1641+ 'includedRules' : [rule_id_1 ],
1642+ 'audienceIds' : [],
1643+ 'variations' : [
1644+ {'id' : 'draft_var' , 'key' : 'draft_control' , 'variables' : []}
1645+ ],
1646+ 'trafficAllocation' : [
1647+ {'entityId' : 'draft_var' , 'endOfRange' : 10000 }
1648+ ]
1649+ },
1650+ {
1651+ 'id' : 'local_holdout_invalid' ,
1652+ 'key' : 'local_holdout_invalid_rule' ,
1653+ 'status' : 'Running' ,
1654+ 'includedRules' : ['non_existent_rule_id' ],
1655+ 'audienceIds' : [],
1656+ 'variations' : [
1657+ {'id' : 'local_var_invalid' , 'key' : 'invalid_control' , 'variables' : []}
1658+ ],
1659+ 'trafficAllocation' : [
1660+ {'entityId' : 'local_var_invalid' , 'endOfRange' : 10000 }
1661+ ]
1662+ }
1663+ ]
1664+
1665+ config_json = json .dumps (config_dict )
1666+ opt_obj = optimizely .Optimizely (config_json )
1667+ self .project_config = opt_obj .config_manager .get_config ()
1668+
1669+ def test_global_holdouts_correctly_identified (self ):
1670+ """ Test that global holdouts are correctly identified. """
1671+
1672+ global_holdouts = self .project_config .get_global_holdouts ()
1673+ self .assertEqual (1 , len (global_holdouts ))
1674+ self .assertEqual ('global_holdout' , global_holdouts [0 ].key )
1675+ self .assertTrue (global_holdouts [0 ].is_global )
1676+
1677+ def test_rule_holdouts_map (self ):
1678+ """ Test that local holdouts are correctly mapped to rules. """
1679+
1680+ # Rule 1 should have 2 local holdouts
1681+ holdouts_for_rule_1 = self .project_config .get_holdouts_for_rule ('111127' )
1682+ self .assertEqual (2 , len (holdouts_for_rule_1 ))
1683+ holdout_keys_1 = {h .key for h in holdouts_for_rule_1 }
1684+ self .assertIn ('local_holdout_single' , holdout_keys_1 )
1685+ self .assertIn ('local_holdout_multi' , holdout_keys_1 )
1686+
1687+ # Rule 2 should have 1 local holdout
1688+ holdouts_for_rule_2 = self .project_config .get_holdouts_for_rule ('32222' )
1689+ self .assertEqual (1 , len (holdouts_for_rule_2 ))
1690+ self .assertEqual ('local_holdout_multi' , holdouts_for_rule_2 [0 ].key )
1691+
1692+ # Non-existent rule should return empty list
1693+ self .assertEqual (0 , len (self .project_config .get_holdouts_for_rule ('non_existent' )))
1694+
1695+ def test_empty_included_rules_not_mapped (self ):
1696+ """ Test that holdouts with empty includedRules are not mapped to any rules. """
1697+
1698+ for rule_id in ['111127' , '32222' ]:
1699+ holdouts = self .project_config .get_holdouts_for_rule (rule_id )
1700+ holdout_keys = {h .key for h in holdouts }
1701+ self .assertNotIn ('local_holdout_empty' , holdout_keys )
1702+
1703+ def test_draft_holdouts_not_processed (self ):
1704+ """ Test that draft holdouts are not included in global or rule maps. """
1705+
1706+ global_keys = {h .key for h in self .project_config .get_global_holdouts ()}
1707+ self .assertNotIn ('draft_holdout' , global_keys )
1708+
1709+ rule_keys = {h .key for h in self .project_config .get_holdouts_for_rule ('111127' )}
1710+ self .assertNotIn ('draft_holdout' , rule_keys )
1711+
1712+ def test_invalid_rule_ids_handled_silently (self ):
1713+ """ Test that holdouts with non-existent rule IDs don't cause errors. """
1714+
1715+ holdouts = self .project_config .get_holdouts_for_rule ('non_existent_rule_id' )
1716+ self .assertEqual (0 , len (holdouts ))
1717+
1718+ # The holdout entity still exists, just not mapped
1719+ invalid_holdout = self .project_config .get_holdout ('local_holdout_invalid' )
1720+ self .assertIsNotNone (invalid_holdout )
1721+ self .assertEqual ('local_holdout_invalid_rule' , invalid_holdout .key )
1722+
1723+ def test_cross_rule_targeting (self ):
1724+ """ Test that a single holdout can target rules from multiple experiments. """
1725+
1726+ holdouts_1 = self .project_config .get_holdouts_for_rule ('111127' )
1727+ holdouts_2 = self .project_config .get_holdouts_for_rule ('32222' )
1728+
1729+ holdout_keys_1 = {h .key for h in holdouts_1 }
1730+ holdout_keys_2 = {h .key for h in holdouts_2 }
1731+
1732+ self .assertIn ('local_holdout_multi' , holdout_keys_1 )
1733+ self .assertIn ('local_holdout_multi' , holdout_keys_2 )
1734+
1735+ # Same holdout object
1736+ multi_1 = next (h for h in holdouts_1 if h .key == 'local_holdout_multi' )
1737+ multi_2 = next (h for h in holdouts_2 if h .key == 'local_holdout_multi' )
1738+ self .assertEqual (multi_1 .id , multi_2 .id )
1739+
15391740
15401741class FeatureRolloutConfigTest (base .BaseTest ):
15411742 """Tests for Feature Rollout support in ProjectConfig parsing."""
0 commit comments