From c694c1a92a44cffad899e1d96de88fc215e024e8 Mon Sep 17 00:00:00 2001 From: Czekaj Tom <47594493+Xen0Xys@users.noreply.github.com> Date: Mon, 22 Apr 2024 18:34:45 +0200 Subject: [PATCH] feat: add support of SkyCoord for the target property (#80) * :sparkles: Implement SkyCoord support for js side * :sparkles: Implement SkyCoord support for python side * :bug: Fix target support for jslink using shared_target traitlets * :memo: Update example 6 to fix target jslink behavior * :bug: Fix multi-instances flickering * :bug: Fix useless target when used in constructor * :sparkles: Implement coordinate string parsing using new coordinate_parser function * :memo: Add minor documentation for widget.js * :rocket: Add missing target getter type annotation * :bug: Fix coordinate_parser.py splitter * :white_check_mark: Add tests for coordinate_parser.py and aladin target getter and setter * :construction_worker: Add CI for automated python code testing * :white_check_mark: Add more testing cases for existing tests * :bug: Fix initial target when frame is galactic * :art: Use a regex to detect if a string is an object name or a coordinate * :memo: Update CHANGELOG.md * :memo: Add _target and shared_target trait help & remove useless error in trait getter * :art: Fix python import order * :bug: Fix missing event unsubscribe for shared_target * :art: Improve conditional structure for the target setter * :memo: Improve the meaning of a sentence in the changelog * :art: Improve target setter conditions * :sparkles: Add parsing support for coordinate string starting by J, G and B * :white_check_mark: Improve testing for new coordinate parsing functions * :memo: Change docstring format from sphinx to numpy * :art: Improve conditional structure for parse_coordinate_string function * fix: type annotations union for python <3.9 * docs: fix doctring style --------- Co-authored-by: MARCHAND MANON --- .github/workflows/python-tests.yml | 25 +++ CHANGELOG.md | 14 ++ examples/6_Linked-widgets.ipynb | 4 +- js/widget.js | 42 +++-- pyproject.toml | 2 +- src/ipyaladin/__init__.py | 129 ++++++++++++--- src/ipyaladin/coordinate_parser.py | 97 +++++++++++ src/test/test_coordinate_parser.py | 254 +++++++++++++++++++++++++++++ 8 files changed, 528 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/python-tests.yml create mode 100644 src/ipyaladin/coordinate_parser.py create mode 100644 src/test/test_coordinate_parser.py diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 00000000..1034a745 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,25 @@ +name: python-tests +on: + push: + branches: + - master + pull_request: + branches: + - master + # Allows to run this workflow manually from the Actions tab + workflow_dispatch: +jobs: + python-tests: + runs-on: ubuntu-latest + steps: + - name: "Checkout branch" + uses: actions/checkout@v4 + - name: "Set up Python on Ubuntu" + uses: actions/setup-python@v5 + with: + python-version: 3.12 + - name: "Python codestyle" + run: | + pip install ".[dev]" + pip install pytest + pytest . diff --git a/CHANGELOG.md b/CHANGELOG.md index 75af8932..fd380bca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Fixed** for any bug fixes. - **Security** in case of vulnerabilities. +## [Unreleased] + +### Added + +- Support for `astropy.coordinates.SkyCoord` for assigning and reading the `target` property (#80) + +### Fixed + +- Fix asynchronous update for the `target` property (#80) + +### Changed + +- Change the jslink target trait from `target` to `shared_target` (#80) + ## [0.3.0] ### Changed diff --git a/examples/6_Linked-widgets.ipynb b/examples/6_Linked-widgets.ipynb index fe13644c..966b5fc4 100644 --- a/examples/6_Linked-widgets.ipynb +++ b/examples/6_Linked-widgets.ipynb @@ -27,8 +27,8 @@ "c = Aladin(layout=Layout(width=\"33.33%\"), survey=\"P/2MASS/color\", **cosmetic_options)\n", "\n", "# synchronize target between 3 widgets\n", - "widgets.jslink((a, \"target\"), (b, \"target\"))\n", - "widgets.jslink((b, \"target\"), (c, \"target\"))\n", + "widgets.jslink((a, \"shared_target\"), (b, \"shared_target\"))\n", + "widgets.jslink((b, \"shared_target\"), (c, \"shared_target\"))\n", "\n", "# synchronize FoV (zoom level) between 3 widgets\n", "widgets.jslink((a, \"fov\"), (b, \"fov\"))\n", diff --git a/js/widget.js b/js/widget.js index d4e12ee3..8be72e91 100644 --- a/js/widget.js +++ b/js/widget.js @@ -4,6 +4,7 @@ import "./widget.css"; let idxView = 0; function convert_pyname_to_jsname(pyname) { + if (pyname.charAt(0) === "_") pyname = pyname.slice(1); let temp = pyname.split("_"); for (let i = 1; i < temp.length; i++) { temp[i] = temp[i].charAt(0).toUpperCase() + temp[i].slice(1); @@ -34,6 +35,9 @@ function render({ model, el }) { let aladin = new A.aladin(aladinDiv, init_options); idxView += 1; + const ra_dec = init_options["target"].split(" "); + aladin.gotoRaDec(ra_dec[0], ra_dec[1]); + el.appendChild(aladinDiv); /* ------------------- */ @@ -46,27 +50,34 @@ function render({ model, el }) { // the gotoObject call should only happen once. The two booleans prevent the two // listeners from triggering each other and creating a buggy loop. The same trick // is also necessary for the field of view. + + /* Target control */ let target_js = false; let target_py = false; - aladin.on("positionChanged", (position) => { - if (!target_py) { - target_js = true; - model.set("target", `${position.ra} ${position.dec}`); - model.save_changes(); - } else { + // Event triggered when the user moves the map in Aladin Lite + aladin.on("positionChanged", () => { + if (target_py) { target_py = false; + return; } + target_js = true; + const ra_dec = aladin.getRaDec(); + model.set("_target", `${ra_dec[0]} ${ra_dec[1]}`); + model.set("shared_target", `${ra_dec[0]} ${ra_dec[1]}`); + model.save_changes(); }); - model.on("change:target", () => { - if (!target_js) { - target_py = true; - let target = model.get("target"); - aladin.gotoObject(target); - } else { + // Event triggered when the target is changed from the Python side using jslink + model.on("change:shared_target", () => { + if (target_js) { target_js = false; + return; } + target_py = true; + const target = model.get("shared_target"); + const [ra, dec] = target.split(" "); + aladin.gotoRaDec(ra, dec); }); /* Field of View control */ @@ -182,6 +193,11 @@ function render({ model, el }) { model.on("msg:custom", (msg) => { let options = {}; switch (msg["event_name"]) { + case "goto_ra_dec": + const ra = msg["ra"]; + const dec = msg["dec"]; + aladin.gotoRaDec(ra, dec); + break; case "add_catalog_from_URL": aladin.addCatalog(A.catalogFromURL(msg["votable_URL"], msg["options"])); break; @@ -235,7 +251,7 @@ function render({ model, el }) { return () => { // need to unsubscribe the listeners - model.off("change:target"); + model.off("change:shared_target"); model.off("change:fov"); model.off("change:height"); model.off("change:coo_frame"); diff --git a/pyproject.toml b/pyproject.toml index 8e22cf15..2359e992 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "ipyaladin" dynamic = ["version"] -dependencies = ["anywidget"] +dependencies = ["anywidget", "astropy"] readme = "README.md" [project.optional-dependencies] diff --git a/src/ipyaladin/__init__.py b/src/ipyaladin/__init__.py index 2499f88f..6c9fbe99 100644 --- a/src/ipyaladin/__init__.py +++ b/src/ipyaladin/__init__.py @@ -1,9 +1,10 @@ import importlib.metadata import pathlib -from typing import ClassVar +from typing import ClassVar, Union import warnings import anywidget +from astropy.coordinates import SkyCoord from traitlets import ( Float, Int, @@ -17,6 +18,8 @@ Undefined, ) +from .coordinate_parser import parse_coordinate_string + try: __version__ = importlib.metadata.version("ipyaladin") except importlib.metadata.PackageNotFoundError: @@ -29,7 +32,17 @@ class Aladin(anywidget.AnyWidget): # Options for the view initialization height = Int(400).tag(sync=True, init_option=True) - target = Unicode("0 0").tag(sync=True, init_option=True) + _target = Unicode( + "0 0", + help="A private trait that stores the current target of the widget in a string." + " Its public version is the 'target' property that returns an " + "`~astropy.coordinates.SkyCoord` object", + ).tag(sync=True, init_option=True) + shared_target = Unicode( + "0 0", + help="A trait that can be used with `~ipywidgets.widgets.jslink`" + "to link two Aladin Lite widgets targets together", + ).tag(sync=True) fov = Float(60.0).tag(sync=True, init_option=True) survey = Unicode("https://alaskybis.unistra.fr/DSS/DSSColor").tag( sync=True, init_option=True @@ -85,6 +98,7 @@ def _init_options(self): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.target = kwargs.get("target", "0 0") self.on_msg(self._handle_custom_message) def _handle_custom_message(self, model, message, list_of_buffers): # noqa: ARG002 @@ -105,11 +119,52 @@ def _handle_custom_message(self, model, message, list_of_buffers): # noqa: ARG0 elif event_type == "select" and "select" in self.listener_callback: self.listener_callback["select"](message_content) + @property + def target(self) -> SkyCoord: + """The target of the Aladin Lite widget. + + It can be set with either a string of an `~astropy.coordinates.SkyCoord` object. + + Returns + ------- + SkyCoord + An astropy.coordinates.SkyCoord object representing the target. + + """ + ra, dec = self._target.split(" ") + return SkyCoord( + ra=ra, + dec=dec, + frame="icrs", + unit="deg", + ) + + @target.setter + def target(self, target: Union[str, SkyCoord]): + if isinstance(target, str): # If the target is str, parse it + target = parse_coordinate_string(target) + elif not isinstance(target, SkyCoord): # If the target is not str or SkyCoord + raise ValueError( + "target must be a string or an astropy.coordinates.SkyCoord object" + ) + self._target = f"{target.icrs.ra.deg} {target.icrs.dec.deg}" + self.send( + { + "event_name": "goto_ra_dec", + "ra": target.icrs.ra.deg, + "dec": target.icrs.dec.deg, + } + ) + def add_catalog_from_URL(self, votable_URL, votable_options=None): - """load a VOTable table from an url and load its data into the widget - Args: - votable_URL: string url - votable_options: dictionary object""" + """Load a VOTable table from an url and load its data into the widget. + + Parameters + ---------- + votable_URL: str + votable_options: dict + + """ if votable_options is None: votable_options = {} self.send( @@ -123,6 +178,17 @@ def add_catalog_from_URL(self, votable_URL, votable_options=None): # MOCs def add_moc(self, moc, **moc_options): + """Add a MOC to the Aladin-Lite widget. + + Parameters + ---------- + moc : `~mocpy.MOC` or str or dict + The MOC can be provided as a `mocpy.MOC` object, as a string containing an + URL where the MOC can be retrieved, or as a dictionary where the keys are + the HEALPix orders and the values are the pixel indices + (ex: {"1":[1,2,4], "2":[12,13,14,21,23,25]}). + + """ if isinstance(moc, dict): self.send( { @@ -159,10 +225,15 @@ def add_moc(self, moc, **moc_options): ) from imp def add_moc_from_URL(self, moc_URL, moc_options=None): - """load a MOC from a URL and display it in Aladin Lite widget - Arguments: - moc_URL: string url - moc_options: dictionary object""" + """Load a MOC from a URL and display it in Aladin Lite widget. + + Parameters + ---------- + moc_URL: str + An URL to retrieve the MOC from + moc_options: dict + + """ warnings.warn( "add_moc_from_URL is replaced by add_moc that detects automatically" "that the MOC was given as an URL.", @@ -174,12 +245,16 @@ def add_moc_from_URL(self, moc_URL, moc_options=None): self.add_moc(moc_URL, **moc_options) def add_moc_from_dict(self, moc_dict, moc_options=None): - """load a MOC from a dict object and display it in Aladin Lite widget - Arguments: - moc_dict: the dict containing the MOC cells. Key are the HEALPix orders, - values are the pixel indexes, - eg: {"1":[1,2,4], "2":[12,13,14,21,23,25]} - moc_options: dictionary object""" + """Load a MOC from a dict object and display it in Aladin Lite widget. + + Parameters + ---------- + moc_dict: dict + It contains the MOC cells. Key are the HEALPix orders, values are the pixel + indexes, eg: {"1":[1,2,4], "2":[12,13,14,21,23,25]} + moc_options: dict + + """ warnings.warn( "add_moc_from_dict is replaced by add_moc that detects automatically" "that the MOC was given as a dictionary.", @@ -217,8 +292,8 @@ def add_table(self, table, **table_options): meta={"name": "my sample table"}) >>> aladin.add_table(table) And the table should appear in the output of Cell 1! - """ + """ # this library must be installed, and is used in votable operations # http://www.astropy.org/ import io @@ -234,9 +309,10 @@ def add_overlay_from_stcs(self, stc_string, **overlay_options): Parameters ---------- stc_string: str - The STC-S string. + The STC-S string. overlay_options: keyword arguments - TODO: documentation""" + + """ self.send( { "event_name": "add_overlay_from_stcs", @@ -261,10 +337,17 @@ def rectangular_selection(self): # Adding a listener def add_listener(self, listener_type, callback): - """add a listener to the widget - Args: - listener_type: string that can either be 'objectHovered' or 'objClicked' - callback: python function""" + """Add a listener to the widget. + + Parameters + ---------- + listener_type: str + Can either be 'objectHovered' or 'objClicked' + callback: Callable + A python function to be called when the event corresponding to the + listener_type is detected + + """ if listener_type in {"objectHovered", "object_hovered"}: self.listener_callback["object_hovered"] = callback elif listener_type in {"objectClicked", "object_clicked"}: diff --git a/src/ipyaladin/coordinate_parser.py b/src/ipyaladin/coordinate_parser.py new file mode 100644 index 00000000..5dd3dc2a --- /dev/null +++ b/src/ipyaladin/coordinate_parser.py @@ -0,0 +1,97 @@ +from astropy.coordinates import SkyCoord, Angle +import re + + +def parse_coordinate_string(string: str) -> SkyCoord: + """Parse a string containing coordinates. + + Parameters + ---------- + string : str + The string containing the coordinates. + + Returns + ------- + SkyCoord + An `astropy.coordinates.SkyCoord` object representing the coordinates. + + """ + if not _is_coordinate_string(string): + return SkyCoord.from_name(string) + coordinates: tuple[str, str] = _split_coordinate_string(string) + # Parse ra and dec to astropy Angle objects + dec: Angle = Angle(coordinates[1], unit="deg") + if _is_hour_angle_string(coordinates[0]): + ra = Angle(coordinates[0], unit="hour") + else: + ra = Angle(coordinates[0], unit="deg") + # Create SkyCoord object + if string[0] == "B": + return SkyCoord(ra=ra, dec=dec, equinox="B1950", frame="fk4") + if string[0] == "G": + return SkyCoord(l=ra, b=dec, frame="galactic") + return SkyCoord(ra=ra, dec=dec, frame="icrs") + + +def _is_coordinate_string(string: str) -> bool: + """Check if a string is a coordinate string. + + Parameters + ---------- + string : str + The string to check. + + Returns + ------- + bool + True if the string is a coordinate string, False otherwise. + + """ + regex = r"^([JGB].*|[0-9][0-9: hmsd.°′'\"+-]+)$" # noqa RUF001 + return bool(re.match(regex, string)) + + +def _split_coordinate_string(coo: str) -> tuple[str, str]: + """Split a string containing coordinates in two parts. + + Parameters + ---------- + coo : str + The string containing the coordinates. + + Returns + ------- + tuple[str, str] + A tuple containing the two parts of the coordinate as strings. + + """ + # Remove first char if it is J, G or B + jgb_regex = r"^[JGB].*" + if bool(re.match(jgb_regex, coo)): + coo = coo[1:] + # Split string in two parts + split_regex = r"[\s°]" + parts = re.split(split_regex, coo) + parts = [part for part in parts if part] + middle = len(parts) // 2 + first_part = " ".join(parts[:middle]) + second_part = " ".join(parts[middle:]) + return first_part, second_part + + +def _is_hour_angle_string(coo: str) -> bool: + """Check if a string is an hour angle string. + + Parameters + ---------- + coo : str + The string to check. + + Returns + ------- + bool + True if the string is an hour angle string, False otherwise. + + """ + regex = r"[hms°: ]" + return bool(re.search(regex, coo)) diff --git a/src/test/test_coordinate_parser.py b/src/test/test_coordinate_parser.py new file mode 100644 index 00000000..6d4f78f1 --- /dev/null +++ b/src/test/test_coordinate_parser.py @@ -0,0 +1,254 @@ +from ipyaladin import Aladin +from ipyaladin.coordinate_parser import ( + parse_coordinate_string, + _split_coordinate_string, + _is_hour_angle_string, + _is_coordinate_string, +) +from astropy.coordinates import SkyCoord +import pytest + +test_is_coordinate_string_values = [ + ("M 31", False), + ("sgr a*", False), + ("1:12:43.2 31:12:43", True), + ("1:12:43.2 +31:12:43", True), + ("1:12:43.2 -31:12:43", True), + ("1 12 43.2 31 12 43", True), + ("1 12 43.2 +31 12 43", True), + ("1 12 43.2 -31 12 43", True), + ("1h12m43.2s 1d12m43s", True), + ("1h12m43.2s +1d12m43s", True), + ("1h12m43.2s -1d12m43s", True), + ("42.67 25.48", True), + ("42.67 +25.48", True), + ("42.67 -25.48", True), + ("0 0", True), + ("J42.67 25.48", True), + ("G42.67 25.48", True), + ("B42.67 25.48", True), + ('17h 45m 40.0409s -29° 00′ 28.118"', True), # noqa RUF001 + ("17h 45m 40.0409s -29° 00' 28.118\"", True), +] + + +@pytest.mark.parametrize(("inp", "expected"), test_is_coordinate_string_values) +def test_is_coordinate_string(inp, expected): + """Test the function _is_coordinate_string. + + Parameters + ---------- + inp : str + A string with an object name or coordinates to check. + expected : bool + The expected result as a boolean. + + """ + assert _is_coordinate_string(inp) == expected + + +test_split_coordinate_string_values = [ + ("1:12:43.2 31:12:43", ("1:12:43.2", "31:12:43")), + ("1:12:43.2 +31:12:43", ("1:12:43.2", "+31:12:43")), + ("1:12:43.2 -31:12:43", ("1:12:43.2", "-31:12:43")), + ("1 12 43.2 31 12 43", ("1 12 43.2", "31 12 43")), + ("1 12 43.2 +31 12 43", ("1 12 43.2", "+31 12 43")), + ("1 12 43.2 -31 12 43", ("1 12 43.2", "-31 12 43")), + ("1h12m43.2s 1d12m43s", ("1h12m43.2s", "1d12m43s")), + ("1h12m43.2s +1d12m43s", ("1h12m43.2s", "+1d12m43s")), + ("1h12m43.2s -1d12m43s", ("1h12m43.2s", "-1d12m43s")), + ("42.67 25.48", ("42.67", "25.48")), + ("42.67 +25.48", ("42.67", "+25.48")), + ("42.67 -25.48", ("42.67", "-25.48")), + ("0 0", ("0", "0")), + ("J42.67 25.48", ("42.67", "25.48")), + ("G42.67 25.48", ("42.67", "25.48")), + ("B42.67 25.48", ("42.67", "25.48")), +] + + +@pytest.mark.parametrize(("inp", "expected"), test_split_coordinate_string_values) +def test_split_coordinate_string(inp, expected): + """Test the function _split_coordinate_string. + + Parameters + ---------- + inp : str + A string with coordinates to split. + expected : tuple of str + The expected result as a tuple of strings. + + """ + assert _split_coordinate_string(inp) == expected + + +test_is_hour_angle_string_values = [ + ("1:12:43.2", True), + ("1 12 43.2", True), + ("1h12m43.2s", True), + ("42.67", False), + ("0", False), +] + + +@pytest.mark.parametrize(("inp", "expected"), test_is_hour_angle_string_values) +def test_is_hour_angle_string(inp, expected): + """Test the function _is_hour_angle_string. + + Parameters + ---------- + inp : str + A coordinate part as a string. + expected : bool + The expected result as a boolean. + + """ + assert _is_hour_angle_string(inp) == expected + + +test_parse_coordinate_string_values = [ + ("M 31", SkyCoord.from_name("M 31")), + ("sgr a*", SkyCoord.from_name("sgr a*")), + ("α Centauri", SkyCoord.from_name("α Centauri")), # noqa RUF001 + ("* 17 Com", SkyCoord.from_name("* 17 Com")), + ( + "1:12:43.2 31:12:43", + SkyCoord(ra="1:12:43.2", dec="31:12:43", unit=("hour", "deg")), + ), + ( + "1:12:43.2 +31:12:43", + SkyCoord(ra="1:12:43.2", dec="+31:12:43", unit=("hour", "deg")), + ), + ( + "1:12:43.2 -31:12:43", + SkyCoord(ra="1:12:43.2", dec="-31:12:43", unit=("hour", "deg")), + ), + ( + "1 12 43.2 31 12 43", + SkyCoord(ra="1 12 43.2", dec="31 12 43", unit=("hour", "deg")), + ), + ( + "1 12 43.2 +31 12 43", + SkyCoord(ra="1 12 43.2", dec="+31 12 43", unit=("hour", "deg")), + ), + ( + "1 12 43.2 -31 12 43", + SkyCoord(ra="1 12 43.2", dec="-31 12 43", unit=("hour", "deg")), + ), + ( + "1h12m43.2s 1d12m43s", + SkyCoord(ra="1h12m43.2s", dec="1d12m43s", unit=("hour", "deg")), + ), + ( + "1h12m43.2s +1d12m43s", + SkyCoord(ra="1h12m43.2s", dec="+1d12m43s", unit=("hour", "deg")), + ), + ( + "1h12m43.2s -1d12m43s", + SkyCoord(ra="1h12m43.2s", dec="-1d12m43s", unit=("hour", "deg")), + ), + ("42.67 25.48", SkyCoord(ra=42.67, dec=25.48, unit="deg")), + ("42.67 +25.48", SkyCoord(ra=42.67, dec=25.48, unit="deg")), + ("42.67 -25.48", SkyCoord(ra=42.67, dec=-25.48, unit="deg")), + ("0 0", SkyCoord(ra=0, dec=0, unit="deg")), + ("J42.67 25.48", SkyCoord(ra=42.67, dec=25.48, unit="deg")), + ( + "G42.67 25.48", + SkyCoord(l=42.67, b=25.48, unit="deg", frame="galactic"), + ), + ( + "B42.67 25.48", + SkyCoord(ra=42.67, dec=25.48, unit="deg", frame="fk4", equinox="B1950"), + ), + ( + "J12 30 45 +45 30 15", + SkyCoord(ra="12 30 45", dec="45 30 15", unit=("hour", "deg")), + ), + ( + "J03 15 20 -10 20 30", + SkyCoord(ra="03 15 20", dec="-10 20 30", unit=("hour", "deg")), + ), + ("G120.5 -45.7", SkyCoord(l=120.5, b=-45.7, unit="deg", frame="galactic")), + ("G90 0", SkyCoord(l=90, b=0, unit="deg", frame="galactic")), + ("B60 30", SkyCoord(ra=60, dec=30, unit="deg", frame="fk4", equinox="B1950")), + ("B120 -45", SkyCoord(ra=120, dec=-45, unit="deg", frame="fk4", equinox="B1950")), +] + + +@pytest.mark.parametrize(("inp", "expected"), test_parse_coordinate_string_values) +def test_parse_coordinate_string(inp, expected): + """Test the function parse_coordinate_string. + + Parameters + ---------- + inp : str + The string to parse. + expected : SkyCoord + The expected result as a SkyCoord object. + + """ + assert parse_coordinate_string(inp) == expected + + +test_aladin_string_target = [ + "M 31", + "sgr a*", + "α Centauri", # noqa RUF001 + "* 17 Com", + "1:12:43.2 31:12:43", + "1:12:43.2 +31:12:43", + "1:12:43.2 -31:12:43", + "1 12 43.2 31 12 43", + "1 12 43.2 +31 12 43", + "1 12 43.2 -31 12 43", + "1h12m43.2s 1d12m43s", + "1h12m43.2s +1d12m43s", + "1h12m43.2s -1d12m43s", + "42.67 25.48", + "42.67 +25.48", + "42.67 -25.48", + "0 0", + "J42.67 25.48", + "G42.67 25.48", + "B42.67 25.48", + "J12 30 45 +45 30 15", + "J03 15 20 -10 20 30", + "G120.5 -45.7", + "G90 0", + "B60 30", + "B120 -45", +] + + +@pytest.mark.parametrize("target", test_aladin_string_target) +def test_aladin_string_target_set(target): + """Test setting the target of an Aladin object with a string or a SkyCoord object. + + Parameters + ---------- + target : str + The target string. + + """ + aladin = Aladin() + aladin.target = target + parsed_target = parse_coordinate_string(target) + assert aladin.target.icrs.ra.deg == parsed_target.icrs.ra.deg + assert aladin.target.icrs.dec.deg == parsed_target.icrs.dec.deg + + +@pytest.mark.parametrize("target", test_aladin_string_target) +def test_aladin_sky_coord_target_set(target): + """Test setting and getting the target of an Aladin object with a SkyCoord object. + + Parameters + ---------- + target : str + The target string. + + """ + sc_target = parse_coordinate_string(target) + aladin = Aladin() + aladin.target = sc_target + assert aladin.target.icrs.ra.deg == sc_target.icrs.ra.deg + assert aladin.target.icrs.dec.deg == sc_target.icrs.dec.deg