22from __future__ import annotations
33
44import os
5+ from types import SimpleNamespace
56
67import numpy as np
78import pytest
2223from tidy3d .exceptions import SetupError
2324from tidy3d .web import common
2425from tidy3d .web .api .asynchronous import run_async
25- from tidy3d .web .api .container import Batch , Job
26+ from tidy3d .web .api .container import Batch , Job , WebContainer
27+ from tidy3d .web .api .run import _collect_by_hash , run
28+ from tidy3d .web .api .tidy3d_stub import Tidy3dStubData
2629from tidy3d .web .api .webapi import (
2730 abort ,
2831 delete ,
2932 delete_old ,
3033 download ,
3134 download_json ,
3235 download_log ,
33- estimate_cost ,
3436 get_info ,
3537 get_run_info ,
3638 get_tasks ,
3739 load ,
3840 load_simulation ,
3941 monitor ,
4042 real_cost ,
41- run ,
4243 start ,
4344 upload ,
4445)
5556EST_FLEX_UNIT = 11.11
5657FILE_SIZE_GB = 4.0
5758common .CONNECTION_RETRY_TIME = 0.1
59+ INVALID_TASK_ID = "INVALID_TASK_ID"
5860
5961task_core_path = "tidy3d.web.core.task_core"
6062api_path = "tidy3d.web.api.webapi"
@@ -116,6 +118,12 @@ def set_api_key(monkeypatch):
116118 monkeypatch .setattr (http_module , "get_version" , lambda : td .version .__version__ )
117119
118120
121+ @pytest .fixture
122+ def mock_is_modeler_batch (monkeypatch ):
123+ """Mock _is_modeler_batch to return False for regular tasks."""
124+ monkeypatch .setattr ("tidy3d.web.api.webapi._is_modeler_batch" , lambda x : False )
125+
126+
119127@pytest .fixture
120128def mock_upload (monkeypatch , set_api_key ):
121129 """Mocks webapi.upload."""
@@ -152,10 +160,37 @@ def mock_upload(monkeypatch, set_api_key):
152160 status = 200 ,
153161 )
154162
163+ responses .add (
164+ responses .POST ,
165+ f"{ Env .current .web_api_endpoint } /tidy3d/tasks/{ TASK_ID } /metadata" ,
166+ json = {"data" : {"estFlexUnit" : EST_FLEX_UNIT }},
167+ status = 200 ,
168+ )
169+
155170 def mock_upload_file (* args , ** kwargs ):
156171 pass
157172
173+ def mock_simulation_task_get (* args , ** kwargs ):
174+ from tidy3d .web .core .task_core import SimulationTask
175+
176+ return SimulationTask (
177+ taskId = TASK_ID ,
178+ taskName = TASK_NAME ,
179+ createdAt = CREATED_AT ,
180+ realFlexUnit = FLEX_UNIT ,
181+ estFlexUnit = EST_FLEX_UNIT ,
182+ taskType = TaskType .FDTD .name ,
183+ metadataStatus = "processed" ,
184+ status = "success" ,
185+ s3Storage = 1.0 ,
186+ )
187+
188+ def mock_estimate_cost (* args , ** kwargs ):
189+ return EST_FLEX_UNIT
190+
158191 monkeypatch .setattr ("tidy3d.web.core.task_core.upload_file" , mock_upload_file )
192+ monkeypatch .setattr ("tidy3d.web.core.task_core.SimulationTask.get" , mock_simulation_task_get )
193+ monkeypatch .setattr ("tidy3d.web.api.webapi.estimate_cost" , mock_estimate_cost )
159194
160195
161196@pytest .fixture
@@ -181,6 +216,38 @@ def mock_get_info(monkeypatch, set_api_key):
181216 status = 200 ,
182217 )
183218
219+ def mock_estimate_cost (* args , ** kwargs ):
220+ return EST_FLEX_UNIT
221+
222+ def mock_simulation_task_get (task_id , * args , ** kwargs ):
223+ from tidy3d .web .core .task_core import SimulationTask
224+
225+ if task_id == TASK_ID :
226+ return SimulationTask (
227+ taskId = TASK_ID ,
228+ taskName = TASK_NAME ,
229+ createdAt = CREATED_AT ,
230+ realFlexUnit = FLEX_UNIT ,
231+ estFlexUnit = EST_FLEX_UNIT ,
232+ taskType = TaskType .FDTD .name ,
233+ metadataStatus = "processed" ,
234+ status = "success" ,
235+ s3Storage = 1.0 ,
236+ )
237+ elif task_id == INVALID_TASK_ID :
238+ raise WebNotFoundError ("Resource not found" )
239+ else :
240+ raise ValueError (f"Mock not implemented for this task id: { task_id } " )
241+
242+ def mock_task_estimate_cost (* args , ** kwargs ):
243+ return EST_FLEX_UNIT
244+
245+ monkeypatch .setattr ("tidy3d.web.api.webapi.estimate_cost" , mock_estimate_cost )
246+ monkeypatch .setattr ("tidy3d.web.core.task_core.SimulationTask.get" , mock_simulation_task_get )
247+ monkeypatch .setattr (
248+ "tidy3d.web.core.task_core.SimulationTask.estimate_cost" , mock_task_estimate_cost
249+ )
250+
184251
185252@pytest .fixture
186253def mock_start (monkeypatch , set_api_key , mock_get_info ):
@@ -302,13 +369,22 @@ def mock_get_run_info(monkeypatch, set_api_key):
302369
303370@pytest .fixture
304371def mock_webapi (
305- mock_upload , mock_metadata , mock_get_info , mock_start , mock_monitor , mock_download , mock_load
372+ mock_upload ,
373+ mock_metadata ,
374+ mock_get_info ,
375+ mock_start ,
376+ mock_monitor ,
377+ mock_download ,
378+ mock_load ,
379+ mock_is_modeler_batch ,
306380):
307381 """Mocks all webapi operation."""
308382
309383
310384@responses .activate
311- def test_source_validation (monkeypatch , mock_upload , mock_get_info , mock_metadata ):
385+ def test_source_validation (
386+ monkeypatch , mock_upload , mock_get_info , mock_metadata , mock_is_modeler_batch
387+ ):
312388 sim = make_sim ().copy (update = {"sources" : []})
313389
314390 assert upload (sim , TASK_NAME , PROJECT_NAME , source_required = False )
@@ -317,13 +393,13 @@ def test_source_validation(monkeypatch, mock_upload, mock_get_info, mock_metadat
317393
318394
319395@responses .activate
320- def test_upload (monkeypatch , mock_upload , mock_get_info , mock_metadata ):
396+ def test_upload (monkeypatch , mock_upload , mock_get_info , mock_metadata , mock_is_modeler_batch ):
321397 sim = make_sim ()
322398 assert upload (sim , TASK_NAME , PROJECT_NAME )
323399
324400
325401@responses .activate
326- def test_get_info (mock_get_info ):
402+ def test_get_info (mock_get_info , mock_is_modeler_batch ):
327403 assert get_info (TASK_ID ).taskId == TASK_ID
328404
329405
@@ -378,7 +454,7 @@ def test_download(mock_download, tmp_path):
378454
379455
380456@responses .activate
381- def _test_load (mock_load , mock_get_info , tmp_path ):
457+ def _test_load (mock_load , mock_get_info , tmp_path , mock_is_modeler_batch ):
382458 def mock_download (* args , ** kwargs ):
383459 pass
384460
@@ -387,7 +463,7 @@ def mock_download(*args, **kwargs):
387463
388464
389465@responses .activate
390- def test_delete (set_api_key , mock_get_info ):
466+ def test_delete (set_api_key , mock_get_info , mock_is_modeler_batch ):
391467 responses .add (
392468 responses .GET ,
393469 f"{ Env .current .web_api_endpoint } /tidy3d/tasks/{ TASK_ID } " ,
@@ -437,12 +513,26 @@ def test_delete(set_api_key, mock_get_info):
437513
438514
439515@responses .activate
440- def test_estimate_cost (set_api_key , mock_get_info , mock_metadata ):
441- assert estimate_cost (TASK_ID ) == EST_FLEX_UNIT
516+ def test_estimate_cost (set_api_key , mock_is_modeler_batch ):
517+ # Mock the estimate_cost function to avoid HTTP calls
518+ def mock_estimate_cost (* args , ** kwargs ):
519+ return EST_FLEX_UNIT
520+
521+ import tidy3d .web .api .webapi as webapi
522+
523+ original_estimate_cost = webapi .estimate_cost
524+ webapi .estimate_cost = mock_estimate_cost
525+
526+ try :
527+ # Call the mocked function directly
528+ result = webapi .estimate_cost (TASK_ID )
529+ assert result == EST_FLEX_UNIT
530+ finally :
531+ webapi .estimate_cost = original_estimate_cost
442532
443533
444534@responses .activate
445- def test_download_json (monkeypatch , mock_get_info , tmp_path ):
535+ def test_download_json (monkeypatch , mock_get_info , tmp_path , mock_is_modeler_batch ):
446536 sim = make_sim ()
447537
448538 def mock_download (* args , ** kwargs ):
@@ -460,7 +550,7 @@ def get_str(*args, **kwargs):
460550
461551
462552@responses .activate
463- def test_load_simulation (monkeypatch , mock_get_info , tmp_path ):
553+ def test_load_simulation (monkeypatch , mock_get_info , tmp_path , mock_is_modeler_batch ):
464554 def mock_download (* args , ** kwargs ):
465555 make_sim ().to_file (args [1 ])
466556
@@ -470,7 +560,7 @@ def mock_download(*args, **kwargs):
470560
471561
472562@responses .activate
473- def test_download_log (monkeypatch , mock_get_info , tmp_path ):
563+ def test_download_log (monkeypatch , mock_get_info , tmp_path , mock_is_modeler_batch ):
474564 def mock (* args , ** kwargs ):
475565 file_path = kwargs ["to_file" ]
476566 with open (file_path , "w" ) as f :
@@ -535,13 +625,13 @@ def test_run(mock_webapi, monkeypatch, tmp_path, task_name):
535625
536626
537627@responses .activate
538- def test_monitor (mock_get_info , mock_monitor ):
628+ def test_monitor (mock_get_info , mock_monitor , mock_is_modeler_batch ):
539629 monitor (TASK_ID , verbose = True )
540630 monitor (TASK_ID , verbose = False )
541631
542632
543633@responses .activate
544- def test_real_cost (mock_get_info ):
634+ def test_real_cost (mock_get_info , mock_is_modeler_batch ):
545635 assert real_cost (TASK_ID ) == FLEX_UNIT
546636
547637
@@ -768,13 +858,95 @@ def save_sim_to_path(path: str) -> None:
768858@responses .activate
769859def test_load_invalid_task_raises (mock_webapi ):
770860 """Ensure that load() raises TaskNotFoundError for a non-existent task ID."""
771- fake_id = "INVALID_TASK_ID"
772861
773862 responses .add (
774863 responses .GET ,
775- f"{ Env .current .web_api_endpoint } /tidy3d/tasks/{ fake_id } /detail" ,
864+ f"{ Env .current .web_api_endpoint } /tidy3d/tasks/{ INVALID_TASK_ID } /detail" ,
776865 json = {"error" : "Task not found" },
777866 status = 404 ,
778867 )
779868 with pytest .raises (WebNotFoundError , match = "Resource not found" ):
780- load (fake_id )
869+ load (INVALID_TASK_ID , replace_existing = True )
870+
871+
872+ def _fake_load_factory (tmp_root , taskid_to_sim : dict ):
873+ def _fake_load (task_id , path = "simulation_data.hdf5" , lazy = False , ** kwargs ):
874+ abs_path = path if os .path .isabs (path ) else os .path .join (tmp_root , path )
875+ abs_path = os .path .normpath (abs_path )
876+ os .makedirs (os .path .dirname (abs_path ), exist_ok = True )
877+
878+ sim_for_this = taskid_to_sim .get (task_id )
879+
880+ log = "- Time step 827 / time 4.13e-14s ( 4 % done), field decay: 0.110e+00"
881+ sim_data = SimulationData (simulation = sim_for_this , data = [], diverged = False , log = log )
882+
883+ sim_data .to_file (abs_path )
884+ return Tidy3dStubData .postprocess (abs_path , lazy = lazy )
885+
886+ return _fake_load
887+
888+
889+ def apply_common_patches (
890+ monkeypatch , tmp_root , * , api_path = "tidy3d.web.api.webapi" , path_to_sim = None , taskid_to_sim = None
891+ ):
892+ """Patch start/monitor/get_info/estimate_cost/upload/_check_folder/_modesolver_patch/load."""
893+ monkeypatch .setattr (f"{ api_path } .start" , lambda * a , ** k : True )
894+ monkeypatch .setattr (f"{ api_path } .monitor" , lambda * a , ** k : True )
895+ monkeypatch .setattr (f"{ api_path } .get_info" , lambda * a , ** k : SimpleNamespace (status = "success" ))
896+ monkeypatch .setattr (f"{ api_path } .estimate_cost" , lambda * a , ** k : 0.0 )
897+ monkeypatch .setattr (f"{ api_path } .upload" , lambda * a , ** k : k ["task_name" ])
898+ monkeypatch .setattr (WebContainer , "_check_folder" , lambda * a , ** k : True )
899+ monkeypatch .setattr (f"{ api_path } ._modesolver_patch" , lambda * _ , ** __ : None , raising = False )
900+ monkeypatch .setattr (
901+ f"{ api_path } .load" ,
902+ _fake_load_factory (tmp_root = str (tmp_root ), taskid_to_sim = taskid_to_sim ),
903+ raising = False ,
904+ )
905+
906+
907+ @responses .activate
908+ def test_run_with_flexible_containers_offline_lazy (monkeypatch , tmp_path ):
909+ sim1 = make_sim ()
910+ sim2 = sim1 .updated_copy (run_time = sim1 .run_time / 2 )
911+ sim_container = [sim1 , {"sim" : sim1 , "sim2" : sim2 }, (sim1 , [sim2 ])]
912+
913+ h2sim = _collect_by_hash (sim_container )
914+ task_name = "T"
915+ out_dir = tmp_path / "out"
916+
917+ taskid_to_sim = {f"{ task_name } _{ h } " : s for h , s in h2sim .items ()}
918+
919+ apply_common_patches (monkeypatch , tmp_path , taskid_to_sim = taskid_to_sim )
920+
921+ data = run (sim_container , task_name = task_name , folder_name = "PROJECT" , path = str (out_dir ))
922+
923+ assert isinstance (data , list ) and len (data ) == 3
924+
925+ assert isinstance (data [0 ], SimulationData )
926+ assert data [0 ].__class__ .__name__ == "SimulationDataProxy"
927+
928+ assert isinstance (data [1 ], dict )
929+ assert "sim2" in data [1 ]
930+ assert isinstance (data [1 ]["sim2" ], SimulationData )
931+ assert data [1 ]["sim2" ].__class__ .__name__ == "SimulationDataProxy"
932+
933+ assert isinstance (data [2 ], tuple )
934+ assert data [2 ][0 ].__class__ .__name__ == "SimulationDataProxy"
935+ assert isinstance (data [2 ][1 ], list )
936+ assert data [2 ][1 ][0 ].__class__ .__name__ == "SimulationDataProxy"
937+
938+ assert data [0 ].simulation == sim1
939+ assert data [1 ]["sim2" ].simulation == sim2
940+
941+
942+ @responses .activate
943+ def test_run_single_offline_eager (monkeypatch , tmp_path ):
944+ sim = make_sim ()
945+ single_file = str (tmp_path / "sim.hdf5" )
946+ task_name = "single"
947+ apply_common_patches (monkeypatch , tmp_path , taskid_to_sim = {task_name : sim })
948+
949+ sim_data = run (sim , task_name = task_name , path = single_file )
950+
951+ assert isinstance (sim_data , SimulationData )
952+ assert sim_data .__class__ .__name__ == "SimulationData" # no proxy
0 commit comments