datalab-platform 1.0.3__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. datalab/__init__.py +1 -1
  2. datalab/config.py +4 -0
  3. datalab/control/baseproxy.py +160 -0
  4. datalab/control/remote.py +175 -1
  5. datalab/data/doc/DataLab_en.pdf +0 -0
  6. datalab/data/doc/DataLab_fr.pdf +0 -0
  7. datalab/data/icons/control/copy_connection_info.svg +11 -0
  8. datalab/data/icons/control/start_webapi_server.svg +19 -0
  9. datalab/data/icons/control/stop_webapi_server.svg +7 -0
  10. datalab/gui/docks.py +3 -2
  11. datalab/gui/main.py +221 -2
  12. datalab/gui/settings.py +10 -0
  13. datalab/gui/tour.py +2 -3
  14. datalab/locale/fr/LC_MESSAGES/datalab.mo +0 -0
  15. datalab/locale/fr/LC_MESSAGES/datalab.po +95 -1
  16. datalab/tests/__init__.py +32 -1
  17. datalab/tests/backbone/config_unit_test.py +1 -1
  18. datalab/tests/backbone/main_app_test.py +4 -0
  19. datalab/tests/backbone/memory_leak.py +1 -1
  20. datalab/tests/features/common/createobject_unit_test.py +1 -1
  21. datalab/tests/features/common/misc_app_test.py +5 -0
  22. datalab/tests/features/control/call_method_unit_test.py +104 -0
  23. datalab/tests/features/control/embedded1_unit_test.py +8 -0
  24. datalab/tests/features/control/remoteclient_app_test.py +39 -35
  25. datalab/tests/features/control/simpleclient_unit_test.py +7 -3
  26. datalab/tests/features/hdf5/h5browser2_unit.py +1 -1
  27. datalab/tests/features/image/background_dialog_test.py +2 -2
  28. datalab/tests/features/image/imagetools_unit_test.py +1 -1
  29. datalab/tests/features/signal/baseline_dialog_test.py +1 -1
  30. datalab/tests/webapi_test.py +395 -0
  31. datalab/webapi/__init__.py +95 -0
  32. datalab/webapi/actions.py +318 -0
  33. datalab/webapi/adapter.py +642 -0
  34. datalab/webapi/controller.py +379 -0
  35. datalab/webapi/routes.py +576 -0
  36. datalab/webapi/schema.py +198 -0
  37. datalab/webapi/serialization.py +388 -0
  38. datalab/widgets/status.py +61 -0
  39. {datalab_platform-1.0.3.dist-info → datalab_platform-1.1.0.dist-info}/METADATA +11 -7
  40. {datalab_platform-1.0.3.dist-info → datalab_platform-1.1.0.dist-info}/RECORD +46 -34
  41. {datalab_platform-1.0.3.dist-info → datalab_platform-1.1.0.dist-info}/WHEEL +1 -1
  42. /datalab/data/icons/{libre-gui-link.svg → control/libre-gui-link.svg} +0 -0
  43. /datalab/data/icons/{libre-gui-unlink.svg → control/libre-gui-unlink.svg} +0 -0
  44. {datalab_platform-1.0.3.dist-info → datalab_platform-1.1.0.dist-info}/entry_points.txt +0 -0
  45. {datalab_platform-1.0.3.dist-info → datalab_platform-1.1.0.dist-info}/licenses/LICENSE +0 -0
  46. {datalab_platform-1.0.3.dist-info → datalab_platform-1.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,104 @@
1
+ # Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
2
+
3
+ """
4
+ Test for call_method generic proxy feature
5
+ """
6
+
7
+ # guitest: show
8
+
9
+ from __future__ import annotations
10
+
11
+ import xmlrpc.client
12
+
13
+ import numpy as np
14
+ from sigima.tests.data import get_test_image, get_test_signal
15
+
16
+ from datalab.tests import datalab_in_background_context
17
+
18
+
19
+ def test_call_method() -> None:
20
+ """Test call_method generic proxy feature"""
21
+ with datalab_in_background_context() as proxy:
22
+ # Test 1: Add some test data
23
+ signal = get_test_signal("paracetamol.txt")
24
+ proxy.add_object(signal)
25
+ image = get_test_image("flower.npy")
26
+ proxy.add_object(image)
27
+
28
+ # Test 2: Call remove_object without panel parameter (auto-detection)
29
+ # Should find method on current panel (signal panel)
30
+ proxy.set_current_panel("signal")
31
+ titles_before = proxy.get_object_titles("signal")
32
+ assert len(titles_before) == 1, "Should have one signal"
33
+
34
+ # Remove the signal object using call_method (no panel parameter)
35
+ # This should auto-detect and use the current panel
36
+ proxy.call_method("remove_object", force=True)
37
+
38
+ titles_after = proxy.get_object_titles("signal")
39
+ assert len(titles_after) == 0, "Signal should be removed"
40
+
41
+ # Test 3: Call method on specific panel
42
+ proxy.set_current_panel("image")
43
+ titles_before = proxy.get_object_titles("image")
44
+ assert len(titles_before) == 1, "Should have one image"
45
+
46
+ # Remove the image object using call_method with explicit panel parameter
47
+ # This tests the panel parameter is correctly passed through XML-RPC
48
+ proxy.call_method("remove_object", force=True, panel="image")
49
+
50
+ titles_after = proxy.get_object_titles("image")
51
+ assert len(titles_after) == 0, "Image should be removed"
52
+
53
+ # Test 3b: Verify panel parameter works when calling from different panel
54
+ x = np.linspace(0, 10, 100)
55
+ y = np.sin(x)
56
+ proxy.add_signal("Test Signal X", x, y)
57
+ proxy.set_current_panel("signal")
58
+ assert len(proxy.get_object_titles("signal")) == 1
59
+ # Call remove_object on signal panel while being on signal panel
60
+ proxy.call_method("remove_object", force=True, panel="signal")
61
+ assert len(proxy.get_object_titles("signal")) == 0
62
+
63
+ # Test 4: Test method resolution order (main window -> current panel)
64
+ # First verify we can call a main window method
65
+ current_panel = proxy.call_method("get_current_panel")
66
+ assert current_panel in ["signal", "image"], "Should return valid panel name"
67
+
68
+ # Test 5: Add more test data and test other methods
69
+ x = np.linspace(0, 10, 100)
70
+ y = np.sin(x)
71
+ proxy.add_signal("Test Signal 1", x, y)
72
+ proxy.add_signal("Test Signal 2", x, y * 2)
73
+
74
+ # Test calling a method with positional and keyword arguments
75
+ proxy.set_current_panel("signal")
76
+ proxy.select_objects([1, 2])
77
+
78
+ # Test delete_metadata through call_method
79
+ proxy.call_method("delete_metadata", refresh_plot=False, keep_roi=True)
80
+
81
+ # Test 5: Error handling - try to call a private method
82
+ # Note: XML-RPC converts exceptions to xmlrpc.client.Fault
83
+ try:
84
+ proxy.call_method("__init__")
85
+ assert False, "Should not allow calling private methods"
86
+ except xmlrpc.client.Fault as exc:
87
+ assert "private method" in exc.faultString.lower()
88
+
89
+ # Test 6: Error handling - try to call non-existent method
90
+ try:
91
+ proxy.call_method("this_method_does_not_exist")
92
+ assert False, "Should raise AttributeError for non-existent method"
93
+ except xmlrpc.client.Fault as exc:
94
+ assert "does not exist" in exc.faultString.lower()
95
+
96
+ # Test 7: Call main window method (not panel method)
97
+ panel_name = proxy.call_method("get_current_panel")
98
+ assert panel_name == "signal", "Should return current panel name"
99
+
100
+ print("✅ All call_method tests passed!")
101
+
102
+
103
+ if __name__ == "__main__":
104
+ test_call_method()
@@ -164,6 +164,14 @@ class AbstractClientWindow(QW.QMainWindow, metaclass=AbstractClientWindowMeta):
164
164
  obj = func(title=self.sigtitle)
165
165
  self.add_object(obj)
166
166
  self.host.log(f"Added signal: {obj.title}")
167
+ # Remove last added signal to test remove_object
168
+ nobj0 = len(self.datalab.get_object_titles())
169
+ sig = self.datalab.get_object() # Get the last added signal
170
+ self.datalab.remove_object(force=True) # Test remove object method
171
+ assert len(self.datalab.get_object_titles()) == nobj0 - 1, (
172
+ "One object should have been removed"
173
+ )
174
+ self.add_object(sig) # Add it back
167
175
 
168
176
  def add_images(self):
169
177
  """Add images to DataLab"""
@@ -20,7 +20,7 @@ from qtpy import QtWidgets as QW
20
20
  from datalab.config import _
21
21
  from datalab.control.proxy import RemoteProxy
22
22
  from datalab.env import execenv
23
- from datalab.tests import run_datalab_in_background
23
+ from datalab.tests import close_datalab_background, run_datalab_in_background
24
24
  from datalab.tests.features.control import embedded1_unit_test
25
25
  from datalab.tests.features.control.remoteclient_unit import multiple_commands
26
26
  from datalab.utils.qthelpers import bring_to_front
@@ -179,40 +179,44 @@ def qt_wait_print(dt: float, message: str, parent=None):
179
179
  def test_remote_client():
180
180
  """Remote client application test"""
181
181
  run_datalab_in_background()
182
-
183
- with qt_app_context(exec_loop=True):
184
- window = HostWindow()
185
- window.resize(800, 800)
186
- window.show()
187
- dt = 1
188
- if execenv.unattended:
189
- qt_wait(8, show_message=True, parent=window)
190
- window.init_cdl()
191
- with qt_wait_print(dt, "Executing multiple commands"):
192
- window.exec_multiple_cmd()
193
- bring_to_front(window)
194
- with qt_wait_print(dt, "Raising DataLab window"):
195
- window.raise_cdl()
196
- with qt_wait_print(dt, "Import macro"):
197
- window.import_macro()
198
- with qt_wait_print(dt, "Getting object titles"):
199
- window.get_object_titles()
200
- with qt_wait_print(dt, "Getting object uuids"):
201
- window.get_object_uuids()
202
- with qt_wait_print(dt, "Getting object"):
203
- window.get_object()
204
- with qt_wait_print(dt, "Adding signals"):
205
- window.add_signals()
206
- with qt_wait_print(dt, "Adding images"):
207
- window.add_images()
208
- with qt_wait_print(dt, "Run macro"):
209
- window.run_macro()
210
- with qt_wait_print(dt * 2, "Stop macro"):
211
- window.stop_macro()
212
- with qt_wait_print(dt, "Removing all objects"):
213
- window.remove_all()
214
- with qt_wait_print(dt, "Closing DataLab"):
215
- window.close_datalab()
182
+ try:
183
+ with qt_app_context(exec_loop=True):
184
+ window = HostWindow()
185
+ window.resize(800, 800)
186
+ window.show()
187
+ dt = 1
188
+ if execenv.unattended:
189
+ qt_wait(8, show_message=True, parent=window)
190
+ window.init_cdl()
191
+ with qt_wait_print(dt, "Executing multiple commands"):
192
+ window.exec_multiple_cmd()
193
+ bring_to_front(window)
194
+ with qt_wait_print(dt, "Raising DataLab window"):
195
+ window.raise_cdl()
196
+ with qt_wait_print(dt, "Import macro"):
197
+ window.import_macro()
198
+ with qt_wait_print(dt, "Getting object titles"):
199
+ window.get_object_titles()
200
+ with qt_wait_print(dt, "Getting object uuids"):
201
+ window.get_object_uuids()
202
+ with qt_wait_print(dt, "Getting object"):
203
+ window.get_object()
204
+ with qt_wait_print(dt, "Adding signals"):
205
+ window.add_signals()
206
+ with qt_wait_print(dt, "Adding images"):
207
+ window.add_images()
208
+ with qt_wait_print(dt, "Run macro"):
209
+ window.run_macro()
210
+ with qt_wait_print(dt * 2, "Stop macro"):
211
+ window.stop_macro()
212
+ with qt_wait_print(dt, "Removing all objects"):
213
+ window.remove_all()
214
+ with qt_wait_print(dt, "Closing DataLab"):
215
+ window.close_datalab()
216
+ except Exception as exc:
217
+ execenv.print("❌ Remote client test failed.")
218
+ close_datalab_background() # Ensure DataLab is closed in case of failure
219
+ raise exc
216
220
 
217
221
 
218
222
  if __name__ == "__main__":
@@ -159,7 +159,7 @@ def test_with_real_server() -> None:
159
159
 
160
160
  # Launch DataLab application in the background
161
161
  execenv.print("Launching DataLab in background...")
162
- run_datalab_in_background(wait_until_ready=True)
162
+ run_datalab_in_background()
163
163
 
164
164
  # Import and run the comprehensive test from sigima
165
165
  execenv.print("Running comprehensive client tests with real server...")
@@ -176,9 +176,13 @@ def test_with_real_server() -> None:
176
176
  # Run all tests
177
177
  tester.run_comprehensive_test()
178
178
  execenv.print("✨ All tests passed with real DataLab server!")
179
- finally:
180
- # Clean up
179
+ except Exception as exc:
180
+ execenv.print("❌ Some tests failed with real DataLab server.")
181
181
  tester.close_datalab()
182
+ raise exc
183
+
184
+ # Clean up
185
+ tester.close_datalab()
182
186
 
183
187
 
184
188
  def test_version_compatibility() -> None:
@@ -15,7 +15,7 @@ import time
15
15
  import numpy as np
16
16
  import psutil
17
17
  from guidata.qthelpers import qt_app_context
18
- from sigima.tests.vistools import view_curves
18
+ from sigima.viz import view_curves
19
19
 
20
20
  from datalab.env import execenv
21
21
  from datalab.tests import helpers
@@ -15,7 +15,7 @@ import sigima.objects
15
15
  import sigima.params
16
16
  import sigima.proc.image as sipi
17
17
  from guidata.qthelpers import exec_dialog, qt_app_context
18
- from sigima.tests import vistools
18
+ from sigima import viz
19
19
  from sigima.tests.data import create_noisy_gaussian_image
20
20
 
21
21
  from datalab.env import execenv
@@ -58,7 +58,7 @@ def test_image_offset_correction_with_background_dialog() -> None:
58
58
  param.x0, param.y0, param.dx, param.dy = ix0, iy0, ix1 - ix0, iy1 - iy0
59
59
  i2 = sipi.offset_correction(i1, param)
60
60
  i3 = sipi.clip(i2, sigima.params.ClipParam.create(lower=0))
61
- vistools.view_images_side_by_side(
61
+ viz.view_images_side_by_side(
62
62
  [i1, i3],
63
63
  titles=["Original image", "Corrected image"],
64
64
  title="Image offset correction and thresholding",
@@ -12,7 +12,7 @@ Simple image dialog for testing all image tools available in DataLab
12
12
  from guidata.qthelpers import qt_app_context
13
13
  from plotpy.builder import make
14
14
  from sigima.tests.data import create_noisy_gaussian_image
15
- from sigima.tests.vistools import view_image_items
15
+ from sigima.viz import view_image_items
16
16
 
17
17
 
18
18
  def test_image_tools_unit():
@@ -15,7 +15,7 @@ import sigima.objects
15
15
  import sigima.proc.signal as sips
16
16
  from guidata.qthelpers import exec_dialog, qt_app_context
17
17
  from sigima.tests.data import create_paracetamol_signal
18
- from sigima.tests.vistools import view_curves
18
+ from sigima.viz import view_curves
19
19
 
20
20
  from datalab.env import execenv
21
21
  from datalab.widgets.signalbaseline import SignalBaselineDialog
@@ -0,0 +1,395 @@
1
+ # Copyright (c) DataLab Platform Developers, BSD 3-Clause License
2
+ # See LICENSE file for details
3
+
4
+ """
5
+ Web API Tests
6
+ =============
7
+
8
+ Unit and integration tests for the DataLab Web API.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import io
14
+ import zipfile
15
+
16
+ import numpy as np
17
+ import pytest
18
+ from fastapi import FastAPI
19
+ from fastapi.testclient import TestClient
20
+ from sigima import ImageObj, SignalObj
21
+
22
+ from datalab.webapi.routes import (
23
+ generate_auth_token,
24
+ router,
25
+ set_adapter,
26
+ set_auth_token,
27
+ set_server_url,
28
+ )
29
+ from datalab.webapi.schema import (
30
+ MetadataPatchRequest,
31
+ ObjectListResponse,
32
+ ObjectMetadata,
33
+ ObjectType,
34
+ )
35
+ from datalab.webapi.serialization import (
36
+ deserialize_object_from_npz,
37
+ object_to_metadata,
38
+ serialize_object_to_npz,
39
+ )
40
+
41
+
42
+ class TestNPZSerialization:
43
+ """Tests for NPZ serialization module."""
44
+
45
+ def test_signal_round_trip(self):
46
+ """Test serializing and deserializing a SignalObj."""
47
+ # Create a signal
48
+ x = np.linspace(0, 10, 100)
49
+ y = np.sin(x)
50
+ obj = SignalObj()
51
+ obj.set_xydata(x, y)
52
+ obj.title = "Test Signal"
53
+ obj.xlabel = "Time"
54
+ obj.ylabel = "Amplitude"
55
+ obj.xunit = "s"
56
+ obj.yunit = "V"
57
+
58
+ # Serialize
59
+ data = serialize_object_to_npz(obj)
60
+ assert isinstance(data, bytes)
61
+ assert len(data) > 0
62
+
63
+ # Verify it's a valid zip
64
+ buffer = io.BytesIO(data)
65
+ with zipfile.ZipFile(buffer, "r") as zf:
66
+ assert "x.npy" in zf.namelist()
67
+ assert "y.npy" in zf.namelist()
68
+ assert "metadata.json" in zf.namelist()
69
+
70
+ # Deserialize
71
+ result = deserialize_object_from_npz(data)
72
+
73
+ # Verify
74
+ assert type(result).__name__ == "SignalObj"
75
+ np.testing.assert_array_equal(result.x, x)
76
+ np.testing.assert_array_equal(result.y, y)
77
+ assert result.title == "Test Signal"
78
+ assert result.xlabel == "Time"
79
+ assert result.ylabel == "Amplitude"
80
+ assert result.xunit == "s"
81
+ assert result.yunit == "V"
82
+
83
+ def test_signal_with_uncertainties(self):
84
+ """Test signal with dx/dy uncertainties."""
85
+ x = np.linspace(0, 10, 50)
86
+ y = np.cos(x)
87
+ dx = np.ones_like(x) * 0.01
88
+ dy = np.abs(y) * 0.05
89
+
90
+ obj = SignalObj()
91
+ obj.set_xydata(x, y, dx=dx, dy=dy)
92
+ obj.title = "Signal with Errors"
93
+
94
+ data = serialize_object_to_npz(obj)
95
+ result = deserialize_object_from_npz(data)
96
+
97
+ np.testing.assert_array_equal(result.dx, dx)
98
+ np.testing.assert_array_equal(result.dy, dy)
99
+
100
+ def test_image_round_trip(self):
101
+ """Test serializing and deserializing an ImageObj."""
102
+ # Create an image
103
+ data = np.random.rand(128, 128).astype(np.float32)
104
+ obj = ImageObj()
105
+ obj.data = data
106
+ obj.title = "Test Image"
107
+ obj.xlabel = "X"
108
+ obj.ylabel = "Y"
109
+ obj.zlabel = "Intensity"
110
+ obj.x0 = 10.0
111
+ obj.y0 = 20.0
112
+ obj.dx = 0.5
113
+ obj.dy = 0.5
114
+
115
+ # Serialize
116
+ npz_data = serialize_object_to_npz(obj)
117
+ assert isinstance(npz_data, bytes)
118
+
119
+ # Verify structure
120
+ buffer = io.BytesIO(npz_data)
121
+ with zipfile.ZipFile(buffer, "r") as zf:
122
+ assert "data.npy" in zf.namelist()
123
+ assert "metadata.json" in zf.namelist()
124
+
125
+ # Deserialize
126
+ result = deserialize_object_from_npz(npz_data)
127
+
128
+ # Verify
129
+ assert type(result).__name__ == "ImageObj"
130
+ np.testing.assert_array_equal(result.data, data)
131
+ assert result.title == "Test Image"
132
+ assert result.x0 == 10.0
133
+ assert result.y0 == 20.0
134
+ assert result.dx == 0.5
135
+ assert result.dy == 0.5
136
+
137
+ def test_image_preserves_dtype(self):
138
+ """Test that image dtype is preserved through serialization."""
139
+ for dtype in [np.uint8, np.uint16, np.float32, np.float64]:
140
+ obj = ImageObj()
141
+ obj.data = np.random.randint(0, 255, (64, 64)).astype(dtype)
142
+ obj.title = f"Image {dtype.__name__}"
143
+
144
+ data = serialize_object_to_npz(obj)
145
+ result = deserialize_object_from_npz(data)
146
+
147
+ assert result.data.dtype == dtype, (
148
+ f"Expected {dtype}, got {result.data.dtype}"
149
+ )
150
+
151
+
152
+ class TestObjectMetadata:
153
+ """Tests for object_to_metadata helper."""
154
+
155
+ def test_signal_metadata(self):
156
+ """Test extracting metadata from a SignalObj."""
157
+ obj = SignalObj()
158
+ obj.set_xydata(np.linspace(0, 10, 100), np.sin(np.linspace(0, 10, 100)))
159
+ obj.title = "My Signal"
160
+ obj.xlabel = "Time"
161
+
162
+ meta = object_to_metadata(obj, "my_signal")
163
+
164
+ assert meta["name"] == "my_signal"
165
+ assert meta["type"] == "signal"
166
+ assert meta["shape"] == [100]
167
+ assert meta["dtype"] == "float64"
168
+ assert meta["title"] == "My Signal"
169
+ assert meta["xlabel"] == "Time"
170
+
171
+ def test_image_metadata(self):
172
+ """Test extracting metadata from an ImageObj."""
173
+ obj = ImageObj()
174
+ obj.data = np.zeros((256, 512), dtype=np.uint16)
175
+ obj.title = "My Image"
176
+ obj.x0 = 5.0
177
+ obj.dx = 0.1
178
+
179
+ meta = object_to_metadata(obj, "my_image")
180
+
181
+ assert meta["name"] == "my_image"
182
+ assert meta["type"] == "image"
183
+ assert meta["shape"] == [256, 512]
184
+ assert meta["dtype"] == "uint16"
185
+ assert meta["title"] == "My Image"
186
+ assert meta["attributes"]["x0"] == 5.0
187
+ assert meta["attributes"]["dx"] == 0.1
188
+
189
+
190
+ class TestSchemaModels:
191
+ """Tests for Pydantic schema models."""
192
+
193
+ def test_object_metadata_validation(self):
194
+ """Test ObjectMetadata model validation."""
195
+ # Valid metadata
196
+ meta = ObjectMetadata(
197
+ name="test",
198
+ type=ObjectType.SIGNAL,
199
+ shape=[100],
200
+ dtype="float64",
201
+ )
202
+ assert meta.name == "test"
203
+ assert meta.type == ObjectType.SIGNAL
204
+
205
+ # With optional fields
206
+ meta_full = ObjectMetadata(
207
+ name="test2",
208
+ type=ObjectType.IMAGE,
209
+ shape=[256, 256],
210
+ dtype="uint8",
211
+ title="Test Image",
212
+ xlabel="X",
213
+ ylabel="Y",
214
+ attributes={"custom": "value"},
215
+ )
216
+ assert meta_full.attributes == {"custom": "value"}
217
+
218
+ def test_object_list_response(self):
219
+ """Test ObjectListResponse model."""
220
+ objs = [
221
+ ObjectMetadata(
222
+ name="s1", type=ObjectType.SIGNAL, shape=[50], dtype="float64"
223
+ ),
224
+ ObjectMetadata(
225
+ name="i1", type=ObjectType.IMAGE, shape=[64, 64], dtype="uint8"
226
+ ),
227
+ ]
228
+ response = ObjectListResponse(objects=objs, count=2)
229
+
230
+ assert len(response.objects) == 2
231
+ assert response.count == 2
232
+
233
+ def test_metadata_patch_request(self):
234
+ """Test MetadataPatchRequest model."""
235
+ patch = MetadataPatchRequest(title="New Title", xlabel="Updated X")
236
+
237
+ # model_dump should exclude None values
238
+ data = patch.model_dump(exclude_none=True)
239
+ assert "title" in data
240
+ assert "xlabel" in data
241
+ assert "ylabel" not in data
242
+
243
+
244
+ class TestAuthToken:
245
+ """Tests for authentication token handling."""
246
+
247
+ def test_generate_token(self):
248
+ """Test token generation."""
249
+ token1 = generate_auth_token()
250
+ token2 = generate_auth_token()
251
+
252
+ # Tokens should be non-empty strings
253
+ assert isinstance(token1, str)
254
+ assert len(token1) > 20
255
+
256
+ # Each token should be unique
257
+ assert token1 != token2
258
+
259
+
260
+ # Integration tests would require a running DataLab instance
261
+ # They are marked for opt-in execution
262
+
263
+
264
+ class MockWorkspaceAdapter:
265
+ """Mock workspace adapter for testing."""
266
+
267
+ def __init__(self):
268
+ self._objects: dict[str, SignalObj | ImageObj] = {}
269
+
270
+ def add_object(self, name: str, obj: SignalObj | ImageObj) -> None:
271
+ """Add an object to the mock workspace."""
272
+ self._objects[name] = obj
273
+
274
+ def list_objects(self) -> list[tuple[str, str]]:
275
+ """List all objects in the mock workspace."""
276
+ result = []
277
+ for name, obj in self._objects.items():
278
+ panel = "signal" if type(obj).__name__ == "SignalObj" else "image"
279
+ result.append((name, panel))
280
+ return result
281
+
282
+ def get_object(self, name: str) -> SignalObj | ImageObj:
283
+ """Get an object by name."""
284
+ if name not in self._objects:
285
+ raise KeyError(f"Object '{name}' not found")
286
+ return self._objects[name]
287
+
288
+
289
+ class TestAPIEndpointsWithMock:
290
+ """Integration tests using a mock workspace adapter."""
291
+
292
+ @pytest.fixture
293
+ def test_client(self):
294
+ """Create a test client with mock adapter."""
295
+ # Create a fresh app with the router
296
+ app = FastAPI()
297
+ app.include_router(router)
298
+
299
+ # Set up mock adapter and auth
300
+ mock_adapter = MockWorkspaceAdapter()
301
+ test_token = "test-token-12345"
302
+
303
+ set_adapter(mock_adapter)
304
+ set_auth_token(test_token)
305
+ set_server_url("http://localhost:8000")
306
+
307
+ client = TestClient(app)
308
+ return client, test_token, mock_adapter
309
+
310
+ def test_status_endpoint(self, test_client):
311
+ """Test the /api/v1/status endpoint (no auth required)."""
312
+ client, _token, _adapter = test_client
313
+
314
+ response = client.get("/api/v1/status")
315
+
316
+ assert response.status_code == 200
317
+ data = response.json()
318
+ assert data["running"] is True
319
+ assert "version" in data
320
+ assert data["api_version"] == "v1"
321
+ assert data["url"] == "http://localhost:8000"
322
+ assert data["workspace_mode"] == "live"
323
+
324
+ def test_list_objects_requires_auth(self, test_client):
325
+ """Test that /api/v1/objects requires authentication."""
326
+ client, _token, _adapter = test_client
327
+
328
+ # Request without Authorization header should return 401
329
+ response = client.get("/api/v1/objects")
330
+
331
+ assert response.status_code == 401
332
+ assert "WWW-Authenticate" in response.headers
333
+ assert response.headers["WWW-Authenticate"] == "Bearer"
334
+
335
+ def test_list_objects_with_invalid_token(self, test_client):
336
+ """Test that /api/v1/objects rejects invalid tokens."""
337
+ client, _token, _adapter = test_client
338
+
339
+ response = client.get(
340
+ "/api/v1/objects", headers={"Authorization": "Bearer wrong-token"}
341
+ )
342
+
343
+ assert response.status_code == 401
344
+
345
+ def test_list_objects_with_valid_token(self, test_client):
346
+ """Test that /api/v1/objects works with valid token."""
347
+ client, token, adapter = test_client
348
+
349
+ # Add a test signal to the mock adapter
350
+ x = np.linspace(0, 10, 50)
351
+ y = np.sin(x)
352
+ signal = SignalObj()
353
+ signal.set_xydata(x, y)
354
+ signal.title = "Test Signal"
355
+ adapter.add_object("Test Signal", signal)
356
+
357
+ response = client.get(
358
+ "/api/v1/objects", headers={"Authorization": f"Bearer {token}"}
359
+ )
360
+
361
+ assert response.status_code == 200
362
+ data = response.json()
363
+ assert data["count"] == 1
364
+ assert len(data["objects"]) == 1
365
+ assert data["objects"][0]["name"] == "Test Signal"
366
+ assert data["objects"][0]["type"] == "signal"
367
+
368
+ def test_list_objects_empty_workspace(self, test_client):
369
+ """Test listing objects in an empty workspace."""
370
+ client, token, _adapter = test_client
371
+
372
+ response = client.get(
373
+ "/api/v1/objects", headers={"Authorization": f"Bearer {token}"}
374
+ )
375
+
376
+ assert response.status_code == 200
377
+ data = response.json()
378
+ assert data["count"] == 0
379
+ assert data["objects"] == []
380
+
381
+ def test_invalid_auth_header_format(self, test_client):
382
+ """Test that malformed Authorization headers are rejected."""
383
+ client, _token, _adapter = test_client
384
+
385
+ # Missing "Bearer" prefix
386
+ response = client.get(
387
+ "/api/v1/objects", headers={"Authorization": "test-token-12345"}
388
+ )
389
+ assert response.status_code == 401
390
+
391
+ # Wrong prefix
392
+ response = client.get(
393
+ "/api/v1/objects", headers={"Authorization": "Basic test-token-12345"}
394
+ )
395
+ assert response.status_code == 401