@@ -2291,6 +2291,173 @@ public void testDecide_withDefaultDecideOptions() throws IOException {
22912291 assertTrue (decision .getReasons ().size () > 0 );
22922292 }
22932293
2294+ // Holdout Tests
2295+
2296+ private OptimizelyClient createOptimizelyClientWithHoldouts (Context context ) throws IOException {
2297+ String holdoutDatafile = loadRawResource (context , R .raw .holdouts_project_config );
2298+ OptimizelyManager optimizelyManager = OptimizelyManager .builder (testProjectId ).build (context );
2299+ optimizelyManager .initialize (context , holdoutDatafile );
2300+ return optimizelyManager .getOptimizely ();
2301+ }
2302+
2303+ @ Test
2304+ public void testDecide_withHoldout () throws IOException {
2305+ assumeTrue (datafileVersion == Integer .parseInt (ProjectConfig .Version .V4 .toString ()));
2306+
2307+ Context context = InstrumentationRegistry .getInstrumentation ().getTargetContext ();
2308+ OptimizelyClient optimizelyClient = createOptimizelyClientWithHoldouts (context );
2309+
2310+ String flagKey = "boolean_feature" ;
2311+ String userId = "user123" ;
2312+ String variationKey = "ho_off_key" ;
2313+ String ruleKey = "basic_holdout" ;
2314+
2315+ Map <String , Object > attributes = new HashMap <>();
2316+ attributes .put ("$opt_bucketing_id" , "ppid160000" ); // deterministic bucketing into basic_holdout
2317+ attributes .put ("nationality" , "English" ); // non-reserved attribute
2318+
2319+ OptimizelyUserContext userContext = optimizelyClient .createUserContext (userId , attributes );
2320+ OptimizelyDecision decision = userContext .decide (flagKey , Collections .singletonList (OptimizelyDecideOption .INCLUDE_REASONS ));
2321+
2322+ // Validate holdout decision
2323+ assertEquals (flagKey , decision .getFlagKey ());
2324+ assertEquals (variationKey , decision .getVariationKey ());
2325+ assertEquals (ruleKey , decision .getRuleKey ());
2326+ assertFalse (decision .getEnabled ());
2327+ assertTrue (decision .getVariables ().toMap ().isEmpty ());
2328+ assertTrue ("Expected holdout reason" , decision .getReasons ().stream ()
2329+ .anyMatch (reason -> reason .contains ("holdout" )));
2330+ }
2331+
2332+ @ Test
2333+ public void testDecideForKeys_withHoldout () throws IOException {
2334+ assumeTrue (datafileVersion == Integer .parseInt (ProjectConfig .Version .V4 .toString ()));
2335+
2336+ Context context = InstrumentationRegistry .getInstrumentation ().getTargetContext ();
2337+ OptimizelyClient optimizelyClient = createOptimizelyClientWithHoldouts (context );
2338+
2339+ String userId = "user123" ;
2340+ String variationKey = "ho_off_key" ;
2341+ String ruleKey = "basic_holdout" ;
2342+
2343+ Map <String , Object > attributes = new HashMap <>();
2344+ attributes .put ("$opt_bucketing_id" , "ppid160000" ); // deterministic bucketing into basic_holdout
2345+
2346+ List <String > flagKeys = Arrays .asList (
2347+ "boolean_feature" ,
2348+ "double_single_variable_feature" ,
2349+ "integer_single_variable_feature"
2350+ );
2351+
2352+ OptimizelyUserContext userContext = optimizelyClient .createUserContext (userId , attributes );
2353+ Map <String , OptimizelyDecision > decisions = userContext .decideForKeys (flagKeys , Collections .singletonList (OptimizelyDecideOption .INCLUDE_REASONS ));
2354+
2355+ assertEquals (3 , decisions .size ());
2356+
2357+ for (String flagKey : flagKeys ) {
2358+ OptimizelyDecision decision = decisions .get (flagKey );
2359+ assertNotNull ("Missing decision for flag " + flagKey , decision );
2360+ assertEquals (flagKey , decision .getFlagKey ());
2361+ assertEquals (variationKey , decision .getVariationKey ());
2362+ assertEquals (ruleKey , decision .getRuleKey ());
2363+ assertFalse (decision .getEnabled ());
2364+ assertTrue ("Expected holdout reason for flag " + flagKey , decision .getReasons ().stream ()
2365+ .anyMatch (reason -> reason .contains ("holdout" )));
2366+ }
2367+ }
2368+
2369+ @ Test
2370+ public void testDecideAll_withHoldout () throws IOException {
2371+ assumeTrue (datafileVersion == Integer .parseInt (ProjectConfig .Version .V4 .toString ()));
2372+
2373+ Context context = InstrumentationRegistry .getInstrumentation ().getTargetContext ();
2374+ OptimizelyClient optimizelyClient = createOptimizelyClientWithHoldouts (context );
2375+
2376+ String userId = "user123" ;
2377+ String variationKey = "ho_off_key" ;
2378+
2379+ Map <String , Object > attributes = new HashMap <>();
2380+ // ppid120000 buckets user into holdout_included_flags (selective holdout)
2381+ attributes .put ("$opt_bucketing_id" , "ppid120000" );
2382+
2383+ // Flags INCLUDED in holdout_included_flags (only these should be holdout decisions)
2384+ List <String > includedInHoldout = Arrays .asList (
2385+ "boolean_feature" ,
2386+ "double_single_variable_feature" ,
2387+ "integer_single_variable_feature"
2388+ );
2389+
2390+ OptimizelyUserContext userContext = optimizelyClient .createUserContext (userId , attributes );
2391+ Map <String , OptimizelyDecision > decisions = userContext .decideAll (Arrays .asList (
2392+ OptimizelyDecideOption .INCLUDE_REASONS ,
2393+ OptimizelyDecideOption .DISABLE_DECISION_EVENT
2394+ ));
2395+
2396+ assertTrue ("Should have multiple decisions" , decisions .size () > 0 );
2397+
2398+ String expectedReason = "User (" + userId + ") is in variation (" + variationKey + ") of holdout (holdout_included_flags)." ;
2399+
2400+ int holdoutCount = 0 ;
2401+ for (Map .Entry <String , OptimizelyDecision > entry : decisions .entrySet ()) {
2402+ String flagKey = entry .getKey ();
2403+ OptimizelyDecision decision = entry .getValue ();
2404+ assertNotNull ("Missing decision for flag " + flagKey , decision );
2405+
2406+ if (includedInHoldout .contains (flagKey )) {
2407+ // Should be holdout decision
2408+ assertEquals (variationKey , decision .getVariationKey ());
2409+ assertFalse (decision .getEnabled ());
2410+ assertTrue ("Expected holdout reason for flag " + flagKey , decision .getReasons ().contains (expectedReason ));
2411+ holdoutCount ++;
2412+ } else {
2413+ // Should NOT be a holdout decision
2414+ assertFalse ("Non-included flag should not have holdout reason: " + flagKey ,
2415+ decision .getReasons ().contains (expectedReason ));
2416+ }
2417+ }
2418+ assertEquals ("Expected exactly the included flags to be in holdout" , includedInHoldout .size (), holdoutCount );
2419+ }
2420+
2421+ @ Test
2422+ public void testDecisionNotificationHandler_withHoldout () throws IOException {
2423+ assumeTrue (datafileVersion == Integer .parseInt (ProjectConfig .Version .V4 .toString ()));
2424+
2425+ Context context = InstrumentationRegistry .getInstrumentation ().getTargetContext ();
2426+ OptimizelyClient optimizelyClient = createOptimizelyClientWithHoldouts (context );
2427+
2428+ String flagKey = "boolean_feature" ;
2429+ String userId = "user123" ;
2430+ String variationKey = "ho_off_key" ;
2431+ String ruleKey = "basic_holdout" ;
2432+
2433+ Map <String , Object > attributes = new HashMap <>();
2434+ attributes .put ("$opt_bucketing_id" , "ppid160000" ); // deterministic bucketing into basic_holdout
2435+ attributes .put ("nationality" , "English" ); // non-reserved attribute
2436+
2437+ final boolean [] listenerCalled = {false };
2438+ optimizelyClient .addDecisionNotificationHandler (decisionNotification -> {
2439+ assertEquals ("FLAG" , decisionNotification .getType ());
2440+ assertEquals (userId , decisionNotification .getUserId ());
2441+ assertEquals (attributes , decisionNotification .getAttributes ());
2442+
2443+ Map <String , ?> info = decisionNotification .getDecisionInfo ();
2444+ assertEquals (flagKey , info .get ("flagKey" ));
2445+ assertEquals (variationKey , info .get ("variationKey" ));
2446+ assertEquals (false , info .get ("enabled" ));
2447+ assertEquals (ruleKey , info .get ("ruleKey" ));
2448+ assertTrue (((Map <?, ?>) info .get ("variables" )).isEmpty ());
2449+
2450+ listenerCalled [0 ] = true ;
2451+ });
2452+
2453+ OptimizelyUserContext userContext = optimizelyClient .createUserContext (userId , attributes );
2454+ OptimizelyDecision decision = userContext .decide (flagKey , Collections .singletonList (OptimizelyDecideOption .INCLUDE_REASONS ));
2455+
2456+ assertTrue ("Decision notification handler should have been called" , listenerCalled [0 ]);
2457+ assertEquals (variationKey , decision .getVariationKey ());
2458+ assertFalse (decision .getEnabled ());
2459+ }
2460+
22942461 // Utils
22952462
22962463 private boolean compareJsonStrings (String str1 , String str2 ) {
0 commit comments