Skip to content

Commit 97e4b63

Browse files
Jules was unable to complete the task in time. Please review the work done so far and provide feedback for Jules to continue.
1 parent c2e6c6a commit 97e4b63

6 files changed

Lines changed: 577 additions & 0 deletions

File tree

examples/planning/tests/.keep

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# This file is used to ensure the directory is tracked by Git.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# This file intentionally left blank.
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import unittest
2+
from unittest.mock import patch, MagicMock
3+
4+
from google.ads.googleads.client import GoogleAdsClient
5+
6+
# Assuming forecast_reach is importable, otherwise, we might need to adjust sys.path
7+
# For now, let's assume it's in the python path or PYTHONPATH is set up correctly
8+
# If not, we might need:
9+
# import sys
10+
# import os
11+
# SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
12+
# PROJECT_ROOT = os.path.dirname(os.path.dirname(SCRIPT_DIR)) # Adjust based on actual structure
13+
# sys.path.insert(0, PROJECT_ROOT)
14+
from examples.planning import forecast_reach
15+
16+
17+
class TestForecastReach(unittest.TestCase):
18+
19+
MOCK_CUSTOMER_ID = "1234567890"
20+
MOCK_LOCATION_ID = "2840" # US
21+
MOCK_CURRENCY_CODE = "USD"
22+
23+
@patch("examples.planning.forecast_reach.GoogleAdsClient.load_from_storage")
24+
def test_main_flow(self, mock_load_client):
25+
# Mock the client and service
26+
mock_google_ads_client = MagicMock(spec=GoogleAdsClient)
27+
mock_reach_plan_service = MagicMock()
28+
mock_google_ads_client.get_service.return_value = mock_reach_plan_service
29+
mock_load_client.return_value = mock_google_ads_client
30+
31+
# Call the main function with mocks
32+
# We need to simulate the behavior of the main function's internal calls.
33+
# The forecast_reach.py script's main calls other functions within it.
34+
# We will test these functions more directly.
35+
36+
# --- Test show_plannable_locations ---
37+
forecast_reach.show_plannable_locations(mock_reach_plan_service)
38+
mock_reach_plan_service.list_plannable_locations.assert_called_once()
39+
40+
# Reset mock for the next call if necessary (though for different functions, it's fine)
41+
mock_reach_plan_service.reset_mock()
42+
43+
# --- Test show_plannable_products ---
44+
forecast_reach.show_plannable_products(
45+
mock_reach_plan_service, self.MOCK_LOCATION_ID
46+
)
47+
mock_reach_plan_service.list_plannable_products.assert_called_once_with(
48+
plannable_location_id=self.MOCK_LOCATION_ID
49+
)
50+
mock_reach_plan_service.reset_mock()
51+
52+
# --- Test forecast_manual_mix (which calls request_reach_curve) ---
53+
# This is the most complex part due to the request construction.
54+
# We need to ensure generate_reach_forecast is called with the correct structure.
55+
56+
# Mock the response for generate_reach_forecast to avoid issues if it expects a return
57+
mock_reach_plan_service.generate_reach_forecast.return_value = MagicMock()
58+
59+
forecast_reach.forecast_manual_mix(
60+
mock_google_ads_client, # main function passes the client
61+
mock_reach_plan_service,
62+
self.MOCK_CUSTOMER_ID,
63+
self.MOCK_LOCATION_ID,
64+
self.MOCK_CURRENCY_CODE,
65+
5000000 # budget_micros
66+
)
67+
68+
self.assertEqual(mock_load_client.call_args[1]["version"], "v19")
69+
mock_google_ads_client.get_service.assert_called_with("ReachPlanService", version="v19")
70+
71+
# Assert generate_reach_forecast was called
72+
mock_reach_plan_service.generate_reach_forecast.assert_called_once()
73+
74+
# Get the actual request passed to generate_reach_forecast
75+
call_args = mock_reach_plan_service.generate_reach_forecast.call_args
76+
request = call_args[1]['request'] # request is a keyword argument
77+
78+
self.assertEqual(request.customer_id, self.MOCK_CUSTOMER_ID)
79+
self.assertEqual(request.campaign_duration.duration_micros, 28 * 24 * 60 * 60 * 1000000) # 28 days in micros
80+
self.assertEqual(request.currency_code, self.MOCK_CURRENCY_CODE)
81+
self.assertEqual(request.targeting.plannable_location_id, self.MOCK_LOCATION_ID)
82+
83+
# Check planned_products (simplified check for brevity, real one would be more detailed)
84+
self.assertEqual(len(request.planned_products), 2)
85+
# Product 1: TrueView
86+
self.assertEqual(request.planned_products[0].plannable_product_code, "TRUEVIEW_IN_STREAM")
87+
self.assertEqual(request.planned_products[0].budget_micros, 3500000) # 70% of 5000000
88+
# Product 2: Bumper
89+
self.assertEqual(request.planned_products[1].plannable_product_code, "BUMPER")
90+
self.assertEqual(request.planned_products[1].budget_micros, 1500000) # 30% of 5000000
91+
92+
# Check targeting details
93+
# Age ranges
94+
self.assertTrue(any(age.age_range == forecast_reach.AgeRangeEnum.AGE_RANGE_18_24 for age in request.targeting.age_ranges))
95+
self.assertTrue(any(age.age_range == forecast_reach.AgeRangeEnum.AGE_RANGE_25_34 for age in request.targeting.age_ranges))
96+
97+
# Genders
98+
self.assertTrue(any(gender.type == forecast_reach.GenderTypeEnum.FEMALE for gender in request.targeting.genders))
99+
self.assertTrue(any(gender.type == forecast_reach.GenderTypeEnum.MALE for gender in request.targeting.genders))
100+
101+
# Devices
102+
self.assertTrue(any(device.type == forecast_reach.DeviceEnum.DESKTOP for device in request.targeting.devices))
103+
self.assertTrue(any(device.type == forecast_reach.DeviceEnum.MOBILE for device in request.targeting.devices))
104+
self.assertTrue(any(device.type == forecast_reach.DeviceEnum.TABLET for device in request.targeting.devices))
105+
106+
# The main function in forecast_reach.py is `main(client, customer_id)`.
107+
# We need to test this main entry point as well.
108+
@patch("examples.planning.forecast_reach.GoogleAdsClient.load_from_storage")
109+
@patch("examples.planning.forecast_reach.show_plannable_locations")
110+
@patch("examples.planning.forecast_reach.show_plannable_products")
111+
@patch("examples.planning.forecast_reach.forecast_manual_mix") # Actually calls forecast_reach_curve internally
112+
@patch("examples.planning.forecast_reach.forecast_suggested_mix")
113+
def test_main_function_calls(self,
114+
mock_forecast_suggested_mix,
115+
mock_forecast_manual_mix,
116+
mock_show_plannable_products,
117+
mock_show_plannable_locations,
118+
mock_load_client):
119+
120+
mock_google_ads_client = MagicMock(spec=GoogleAdsClient)
121+
mock_reach_plan_service = MagicMock()
122+
mock_google_ads_client.get_service.return_value = mock_reach_plan_service
123+
mock_load_client.return_value = mock_google_ads_client
124+
125+
# Call the actual main function from the script
126+
forecast_reach.main(mock_google_ads_client, self.MOCK_CUSTOMER_ID)
127+
128+
# Assertions
129+
mock_load_client.assert_called_once() # Check if load_from_storage was called by the script's main
130+
self.assertEqual(mock_load_client.call_args[1]["version"], "v19")
131+
132+
mock_google_ads_client.get_service.assert_called_with("ReachPlanService", version="v19")
133+
134+
# Check that the helper functions were called by main
135+
mock_show_plannable_locations.assert_called_once_with(mock_reach_plan_service)
136+
137+
# The example script calls show_plannable_products with a hardcoded location_id if not provided
138+
# We need to ensure our test uses the same assumptions or mocks appropriately.
139+
# The script uses _DEFAULT_LOCATION as "2840" (US)
140+
_DEFAULT_LOCATION = "2840" # from forecast_reach.py
141+
_DEFAULT_CURRENCY_CODE = "USD" # from forecast_reach.py
142+
_DEFAULT_BUDGET_MICROS = 5_000_000 # from forecast_reach.py
143+
144+
145+
mock_show_plannable_products.assert_called_once_with(
146+
mock_reach_plan_service, _DEFAULT_LOCATION
147+
)
148+
149+
mock_forecast_manual_mix.assert_called_once_with(
150+
mock_google_ads_client,
151+
mock_reach_plan_service,
152+
self.MOCK_CUSTOMER_ID,
153+
_DEFAULT_LOCATION,
154+
_DEFAULT_CURRENCY_CODE,
155+
_DEFAULT_BUDGET_MICROS,
156+
)
157+
158+
# forecast_suggested_mix is also called in the original script's main
159+
mock_forecast_suggested_mix.assert_called_once_with(
160+
mock_google_ads_client,
161+
mock_reach_plan_service,
162+
self.MOCK_CUSTOMER_ID,
163+
_DEFAULT_LOCATION,
164+
_DEFAULT_CURRENCY_CODE,
165+
_DEFAULT_BUDGET_MICROS,
166+
# The following preferences are hardcoded in the example script's main
167+
# when calling forecast_suggested_mix
168+
is_trueview_ad_format_enabled=True,
169+
is_bumper_ad_format_enabled=True,
170+
is_in_stream_selectable_ad_format_enabled=False,
171+
is_out_stream_selectable_ad_format_enabled=False,
172+
is_non_skippable_ad_format_enabled=False
173+
)
174+
175+
176+
if __name__ == "__main__":
177+
unittest.main()
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import unittest
2+
from unittest.mock import patch, MagicMock, call
3+
from datetime import datetime, timedelta
4+
5+
from google.ads.googleads.client import GoogleAdsClient
6+
from google.ads.googleads.v19.enums.types import keyword_plan_network as keyword_plan_network_enum
7+
from google.ads.googleads.v19.enums.types import keyword_match_type as keyword_match_type_enum
8+
9+
# Assuming generate_forecast_metrics is importable
10+
from examples.planning import generate_forecast_metrics
11+
12+
13+
class TestGenerateForecastMetrics(unittest.TestCase):
14+
15+
MOCK_CUSTOMER_ID = "1234567890"
16+
17+
@patch("examples.planning.generate_forecast_metrics.GoogleAdsClient.load_from_storage")
18+
def test_main_execution(self, mock_load_client):
19+
# Mock GoogleAdsClient and its services
20+
mock_google_ads_client = MagicMock(spec=GoogleAdsClient)
21+
mock_keyword_plan_idea_service = MagicMock()
22+
mock_google_ads_service = MagicMock() # Though not directly used by the forecast part, main might get it.
23+
24+
mock_google_ads_client.get_service.side_effect = lambda service_name, version: {
25+
"KeywordPlanIdeaService": mock_keyword_plan_idea_service,
26+
"GoogleAdsService": mock_google_ads_service,
27+
}[service_name]
28+
29+
mock_load_client.return_value = mock_google_ads_client
30+
31+
# Call the main function
32+
generate_forecast_metrics.main(mock_google_ads_client, self.MOCK_CUSTOMER_ID)
33+
34+
# 1. Assert GoogleAdsClient.load_from_storage was called
35+
mock_load_client.assert_called_once_with(version="v19")
36+
37+
# 2. Assert client.get_service was called for KeywordPlanIdeaService
38+
# and potentially GoogleAdsService if main uses it.
39+
# The script uses get_service for KeywordPlanIdeaService for the core logic.
40+
# It doesn't appear to use GoogleAdsService directly in the forecasting part.
41+
mock_google_ads_client.get_service.assert_any_call("KeywordPlanIdeaService", version="v19")
42+
# Check if GoogleAdsService was also requested if it's part of main's setup
43+
# According to generate_forecast_metrics.py, GoogleAdsService is not explicitly fetched in main.
44+
45+
# 3. Verify the call to keyword_plan_idea_service.generate_keyword_forecast_metrics()
46+
mock_keyword_plan_idea_service.generate_keyword_forecast_metrics.assert_called_once()
47+
48+
# Get the actual request passed to generate_keyword_forecast_metrics
49+
call_args = mock_keyword_plan_idea_service.generate_keyword_forecast_metrics.call_args
50+
request = call_args[1]['request'] # request is a keyword argument or use call_args[0][0] if positional
51+
52+
# Verify customer_id in the request
53+
self.assertEqual(request.customer_id, self.MOCK_CUSTOMER_ID)
54+
55+
# Verify forecast_period
56+
# Dates are based on the day the test runs.
57+
tomorrow = datetime.now().date() + timedelta(days=1)
58+
thirty_days_from_tomorrow = tomorrow + timedelta(days=30)
59+
60+
self.assertEqual(request.forecast_period.start_date.year, tomorrow.year)
61+
self.assertEqual(request.forecast_period.start_date.month, tomorrow.month)
62+
self.assertEqual(request.forecast_period.start_date.day, tomorrow.day)
63+
64+
self.assertEqual(request.forecast_period.end_date.year, thirty_days_from_tomorrow.year)
65+
self.assertEqual(request.forecast_period.end_date.month, thirty_days_from_tomorrow.month)
66+
self.assertEqual(request.forecast_period.end_date.day, thirty_days_from_tomorrow.day)
67+
68+
# Verify CampaignToForecast object (implicitly tests create_campaign_to_forecast)
69+
campaign_to_forecast = request.campaign_to_forecast
70+
71+
self.assertEqual(
72+
campaign_to_forecast.keyword_plan_network,
73+
keyword_plan_network_enum.KeywordPlanNetworkEnum.KeywordPlanNetwork.GOOGLE_SEARCH
74+
)
75+
self.assertIsNotNone(campaign_to_forecast.bidding_strategy)
76+
self.assertEqual(campaign_to_forecast.bidding_strategy.max_cpc_bid_ceiling_micros, 1_000_000) # Default from script
77+
78+
# Geo modifiers (Location)
79+
self.assertEqual(len(campaign_to_forecast.geo_modifiers), 1)
80+
# From the script: _GEO_TARGET_CONSTANT_US = "geoTargetConstants/2840"
81+
self.assertEqual(campaign_to_forecast.geo_modifiers[0].geo_target_constant, "geoTargetConstants/2840")
82+
83+
# Language constant
84+
self.assertEqual(len(campaign_to_forecast.language_constants), 1)
85+
# From the script: _LANGUAGE_CONSTANT_EN = "languageConstants/1000"
86+
self.assertEqual(campaign_to_forecast.language_constants[0], "languageConstants/1000")
87+
88+
# Ad Groups and Keywords
89+
self.assertEqual(len(campaign_to_forecast.ad_groups), 2)
90+
91+
# Ad Group 1
92+
ad_group1 = campaign_to_forecast.ad_groups[0]
93+
self.assertEqual(ad_group1.max_cpc_bid_micros, 2_500_000) # Default from script
94+
95+
# Biddable Keywords for Ad Group 1
96+
# From script: ("mars cruise", BROAD), ("cheap cruise", PHRASE), ("jupiter cruise", EXACT)
97+
self.assertEqual(len(ad_group1.biddable_keywords), 3)
98+
self.assertEqual(ad_group1.biddable_keywords[0].keyword.text, "mars cruise")
99+
self.assertEqual(ad_group1.biddable_keywords[0].keyword.match_type, keyword_match_type_enum.KeywordMatchTypeEnum.KeywordMatchType.BROAD)
100+
self.assertEqual(ad_group1.biddable_keywords[1].keyword.text, "cheap cruise")
101+
self.assertEqual(ad_group1.biddable_keywords[1].keyword.match_type, keyword_match_type_enum.KeywordMatchTypeEnum.KeywordMatchType.PHRASE)
102+
self.assertEqual(ad_group1.biddable_keywords[2].keyword.text, "jupiter cruise")
103+
self.assertEqual(ad_group1.biddable_keywords[2].keyword.match_type, keyword_match_type_enum.KeywordMatchTypeEnum.KeywordMatchType.EXACT)
104+
105+
# Negative Keywords for Ad Group 1
106+
# From script: ("moon walk", BROAD)
107+
self.assertEqual(len(ad_group1.negative_keywords), 1)
108+
self.assertEqual(ad_group1.negative_keywords[0].text, "moon walk")
109+
self.assertEqual(ad_group1.negative_keywords[0].match_type, keyword_match_type_enum.KeywordMatchTypeEnum.KeywordMatchType.BROAD)
110+
111+
# Ad Group 2
112+
ad_group2 = campaign_to_forecast.ad_groups[1]
113+
self.assertEqual(ad_group2.max_cpc_bid_micros, 1_990_000) # Default from script
114+
115+
# Biddable Keywords for Ad Group 2
116+
# From script: ("saturn cruise", BROAD), ("expensive cruise", PHRASE), ("mercury cruise", EXACT)
117+
self.assertEqual(len(ad_group2.biddable_keywords), 3)
118+
self.assertEqual(ad_group2.biddable_keywords[0].keyword.text, "saturn cruise")
119+
self.assertEqual(ad_group2.biddable_keywords[0].keyword.match_type, keyword_match_type_enum.KeywordMatchTypeEnum.KeywordMatchType.BROAD)
120+
self.assertEqual(ad_group2.biddable_keywords[1].keyword.text, "expensive cruise")
121+
self.assertEqual(ad_group2.biddable_keywords[1].keyword.match_type, keyword_match_type_enum.KeywordMatchTypeEnum.KeywordMatchType.PHRASE)
122+
self.assertEqual(ad_group2.biddable_keywords[2].keyword.text, "mercury cruise")
123+
self.assertEqual(ad_group2.biddable_keywords[2].keyword.match_type, keyword_match_type_enum.KeywordMatchTypeEnum.KeywordMatchType.EXACT)
124+
125+
# Negative Keywords for Ad Group 2
126+
# From script: ("venus walk", BROAD)
127+
self.assertEqual(len(ad_group2.negative_keywords), 1)
128+
self.assertEqual(ad_group2.negative_keywords[0].text, "venus walk")
129+
self.assertEqual(ad_group2.negative_keywords[0].match_type, keyword_match_type_enum.KeywordMatchTypeEnum.KeywordMatchType.BROAD)
130+
131+
132+
if __name__ == "__main__":
133+
unittest.main()

0 commit comments

Comments
 (0)