datalab-platform 1.0.4__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.
- datalab/__init__.py +1 -1
- datalab/config.py +4 -0
- datalab/control/baseproxy.py +160 -0
- datalab/control/remote.py +175 -1
- datalab/data/doc/DataLab_en.pdf +0 -0
- datalab/data/doc/DataLab_fr.pdf +0 -0
- datalab/data/icons/control/copy_connection_info.svg +11 -0
- datalab/data/icons/control/start_webapi_server.svg +19 -0
- datalab/data/icons/control/stop_webapi_server.svg +7 -0
- datalab/gui/main.py +221 -2
- datalab/gui/settings.py +10 -0
- datalab/gui/tour.py +2 -3
- datalab/locale/fr/LC_MESSAGES/datalab.mo +0 -0
- datalab/locale/fr/LC_MESSAGES/datalab.po +87 -1
- datalab/tests/__init__.py +32 -1
- datalab/tests/backbone/config_unit_test.py +1 -1
- datalab/tests/backbone/main_app_test.py +4 -0
- datalab/tests/backbone/memory_leak.py +1 -1
- datalab/tests/features/common/createobject_unit_test.py +1 -1
- datalab/tests/features/common/misc_app_test.py +5 -0
- datalab/tests/features/control/call_method_unit_test.py +104 -0
- datalab/tests/features/control/embedded1_unit_test.py +8 -0
- datalab/tests/features/control/remoteclient_app_test.py +39 -35
- datalab/tests/features/control/simpleclient_unit_test.py +7 -3
- datalab/tests/features/hdf5/h5browser2_unit.py +1 -1
- datalab/tests/features/image/background_dialog_test.py +2 -2
- datalab/tests/features/image/imagetools_unit_test.py +1 -1
- datalab/tests/features/signal/baseline_dialog_test.py +1 -1
- datalab/tests/webapi_test.py +395 -0
- datalab/webapi/__init__.py +95 -0
- datalab/webapi/actions.py +318 -0
- datalab/webapi/adapter.py +642 -0
- datalab/webapi/controller.py +379 -0
- datalab/webapi/routes.py +576 -0
- datalab/webapi/schema.py +198 -0
- datalab/webapi/serialization.py +388 -0
- datalab/widgets/status.py +61 -0
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/METADATA +6 -2
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/RECORD +45 -33
- /datalab/data/icons/{libre-gui-link.svg → control/libre-gui-link.svg} +0 -0
- /datalab/data/icons/{libre-gui-unlink.svg → control/libre-gui-unlink.svg} +0 -0
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/WHEEL +0 -0
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/entry_points.txt +0 -0
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {datalab_platform-1.0.4.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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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(
|
|
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
|
-
|
|
180
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|