diff --git a/docs/development/guide.rst b/docs/development/guide.rst index adbca7c..a48bb8f 100644 --- a/docs/development/guide.rst +++ b/docs/development/guide.rst @@ -20,7 +20,7 @@ The folllowing commands demonstrate how to do this using ``conda`` (assuming you conda create -n ffpy -c conda-forge python jupyter astropy # jupyter and astropy are needed for running examples conda activate ffpy - pip install -e ".[docs]" # editable installation with docs dependencies + pip install -e ".[tests,docs]" # editable installation with tests and docs dependencies Now you can run the examples notebooks/scripts in the ``examples/`` directory. @@ -53,7 +53,18 @@ above command and reload the above html file in browser. The Sphinx docs include rendered Jupyter notebooks (via ``nbsphinx``), which can require **pandoc**. If you see an error like ``PandocMissing``, install pandoc first (e.g., ``brew install pandoc`` on macOS). +Unit Tests +---------- + +Unit tests live in the ``tests/`` directory and use ``pytest``. +Make sure you have the virtual environment activated with test dependencies installed (``[tests]``), then run: + +.. code-block:: shell + + pytest tests/ + Development Tests/Examples -------------------------- -Refer to the `examples/development_tests directory `_ of firefly-client GitHub repository. +The ``examples/development_tests/`` directory contains notebooks for manually testing behaviour that requires a live Firefly server. +Refer to the `examples/development_tests directory `_ of the firefly_client GitHub repository. diff --git a/docs/development/new-release-procedure.md b/docs/development/new-release-procedure.md index acc741b..f747669 100644 --- a/docs/development/new-release-procedure.md +++ b/docs/development/new-release-procedure.md @@ -2,7 +2,9 @@ ## Procedure 1. To push a new release you must be a maintainer in pypi ([see pypi below](#pypi)) -2. Bump version in pyproject.toml (this step might be done in the PR) +2. Bump versions (this step might be done in the PR): + - Upgrade `project.version` in `pyproject.toml` + - If this release depends on Firefly server changes that would **break client API** without them (not just behavioral improvements), **wait** until a Firefly release is made. Then raise `MIN_SERVER_VERSION` in `firefly_client/_server_compat.py` to that Firefly release version, and add an entry to the dependency log in the same file. 3. Clean out old distribution - `rm dist/*` 4. Create the distribution diff --git a/examples/development_tests/test-version.ipynb b/examples/development_tests/test-version.ipynb new file mode 100644 index 0000000..b689c3d --- /dev/null +++ b/examples/development_tests/test-version.ipynb @@ -0,0 +1,283 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b89ca528-303e-45a8-abe9-7eb93b0b6825", + "metadata": {}, + "source": [ + "# Test version compatibility\n", + "\n", + "Covers all logical branches of `_confirm_version` and its helper functions, across different server configurations." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "befc8619", + "metadata": {}, + "outputs": [], + "source": [ + "from firefly_client import FireflyClient\n", + "\n", + "# only needed for the mock scenario\n", + "from firefly_client._server_compat import is_server_compatible, FIREFLY_VERSION_KEY\n", + "from unittest.mock import patch, MagicMock" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cf4a4e98", + "metadata": {}, + "outputs": [], + "source": [ + "# Uncomment for debugging outputs\n", + "FireflyClient._debug = True" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "3567a13e", + "metadata": {}, + "outputs": [], + "source": [ + "def pprint_confirm_version(result):\n", + " \"\"\"For pretty-printing the result of _confirm_version() for debugging.\"\"\"\n", + " print(f\"compatible: {result['compatible']}\")\n", + " print(f\"server_version: {result['server_version']!r}\")\n", + " try:\n", + " raw = result['response'].json()\n", + " except Exception:\n", + " raw = result['response'].text[:200]\n", + " print(f\"raw response: {raw}\")" + ] + }, + { + "cell_type": "markdown", + "id": "dcc284a4", + "metadata": {}, + "source": [ + "## Compatible server\n", + "Creating a FireflyClient instance should succeed without errors." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "a3000002", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DEBUG: new instance: http://localhost:8080/firefly\n" + ] + } + ], + "source": [ + "fc = FireflyClient.make_client(url='http://localhost:8080/firefly', launch_browser=False)" + ] + }, + { + "cell_type": "markdown", + "id": "e73b6aaf", + "metadata": {}, + "source": [ + "`_confirm_version()` should return `compatible=True` and the server version string from the `Firefly Version` field in the response `data`." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "6c97d585", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "compatible: True\n", + "server_version: '2026.1-DEV:FIREFLY-1331-version-validation_f9ee'\n", + "raw response: {'success': True, 'data': {'Application Version': '2026.1-DEV:FIREFLY-1331-version-validation_f9ee', 'Built On': 'Wed Apr 29 13:25:44 PDT 2026', 'Git commit': 'f9ee5f1b6', 'Firefly Version': '2026.1-DEV:FIREFLY-1331-version-validation_f9ee'}}\n" + ] + } + ], + "source": [ + "pprint_confirm_version(fc._confirm_version())" + ] + }, + { + "cell_type": "markdown", + "id": "a4000001", + "metadata": {}, + "source": [ + "## Incompatible server version\n", + "\n", + "Intercept the version endpoint response and substitute an old version string, then verify that creating a FireflyClient instance raises `ValueError` with a message explaining how to resolve the issue.\n", + "\n", + "We don't have such a server available, so we mock `_confirm_version` to simulate this scenario." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "c709b81c", + "metadata": {}, + "outputs": [], + "source": [ + "INCOMPATIBLE_VERSION = '2025.3.2' # below MIN_SERVER_VERSION (2025.4)\n", + "\n", + "mock_resp_incompat = MagicMock()\n", + "mock_resp_incompat.status_code = 200\n", + "mock_resp_incompat.json.return_value = {'success': True, 'data': {FIREFLY_VERSION_KEY: INCOMPATIBLE_VERSION}}\n", + "\n", + "ver_incompat = {\n", + " 'compatible': is_server_compatible(INCOMPATIBLE_VERSION),\n", + " 'server_version': INCOMPATIBLE_VERSION,\n", + " 'response': mock_resp_incompat,\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "a4000003", + "metadata": {}, + "outputs": [ + { + "ename": "ValueError", + "evalue": "Version of the provided Firefly server http://localhost:8080/firefly/ is not compatible with this version of firefly_client.\n Server version: 2025.3.2\n Required: >=2025.4\n Please use the URL of a compatible Firefly server\n", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mValueError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[7]\u001b[39m\u001b[32m, line 3\u001b[39m\n\u001b[32m 1\u001b[39m \u001b[38;5;66;03m# Patch _confirm_version at class level so the mock takes effect inside make_client()\u001b[39;00m\n\u001b[32m 2\u001b[39m \u001b[38;5;28;01mwith\u001b[39;00m patch.object(FireflyClient, \u001b[33m'\u001b[39m\u001b[33m_confirm_version\u001b[39m\u001b[33m'\u001b[39m, return_value=ver_incompat):\n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m \u001b[43mFireflyClient\u001b[49m\u001b[43m.\u001b[49m\u001b[43mmake_client\u001b[49m\u001b[43m(\u001b[49m\u001b[43murl\u001b[49m\u001b[43m=\u001b[49m\u001b[33;43m'\u001b[39;49m\u001b[33;43mhttp://localhost:8080/firefly/\u001b[39;49m\u001b[33;43m'\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mlaunch_browser\u001b[49m\u001b[43m=\u001b[49m\u001b[38;5;28;43;01mFalse\u001b[39;49;00m\u001b[43m)\u001b[49m\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/dev/cm/firefly_client/firefly_client/firefly_client.py:223\u001b[39m, in \u001b[36mFireflyClient.make_client\u001b[39m\u001b[34m(cls, url, html_file, launch_browser, channel_override, verbose, token, viewer_override)\u001b[39m\n\u001b[32m 175\u001b[39m \u001b[38;5;129m@classmethod\u001b[39m\n\u001b[32m 176\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mmake_client\u001b[39m(\u001b[38;5;28mcls\u001b[39m, url=_default_url, html_file=_def_html_file, launch_browser=\u001b[38;5;28;01mTrue\u001b[39;00m,\n\u001b[32m 177\u001b[39m channel_override=\u001b[38;5;28;01mNone\u001b[39;00m, verbose=\u001b[38;5;28;01mFalse\u001b[39;00m, token=\u001b[38;5;28;01mNone\u001b[39;00m, viewer_override=\u001b[38;5;28;01mNone\u001b[39;00m):\n\u001b[32m 178\u001b[39m \u001b[38;5;250m \u001b[39m\u001b[33;03m\"\"\"\u001b[39;00m\n\u001b[32m 179\u001b[39m \u001b[33;03m Factory method to create a Firefly client in a plain Python, IPython, or\u001b[39;00m\n\u001b[32m 180\u001b[39m \u001b[33;03m notebook session, and attempt to open a display. If a display cannot be\u001b[39;00m\n\u001b[32m (...)\u001b[39m\u001b[32m 221\u001b[39m \u001b[33;03m A FireflyClient that works in the lab environment\u001b[39;00m\n\u001b[32m 222\u001b[39m \u001b[33;03m \"\"\"\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m223\u001b[39m fc = \u001b[38;5;28;43mcls\u001b[39;49m\u001b[43m(\u001b[49m\u001b[43murl\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mEnv\u001b[49m\u001b[43m.\u001b[49m\u001b[43mresolve_client_channel\u001b[49m\u001b[43m(\u001b[49m\u001b[43mchannel_override\u001b[49m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mhtml_file\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mtoken\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mviewer_override\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m 224\u001b[39m verbose \u001b[38;5;129;01mand\u001b[39;00m Env.show_start_browser_tab_msg(fc.get_firefly_url())\n\u001b[32m 225\u001b[39m launch_browser \u001b[38;5;129;01mand\u001b[39;00m fc.launch_browser()\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/dev/cm/firefly_client/firefly_client/firefly_client.py:272\u001b[39m, in \u001b[36mFireflyClient.__init__\u001b[39m\u001b[34m(self, url, channel, html_file, token, viewer_override)\u001b[39m\n\u001b[32m 270\u001b[39m ver = \u001b[38;5;28mself\u001b[39m._confirm_version()\n\u001b[32m 271\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m ver[\u001b[33m'\u001b[39m\u001b[33mcompatible\u001b[39m\u001b[33m'\u001b[39m]:\n\u001b[32m--> \u001b[39m\u001b[32m272\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(\n\u001b[32m 273\u001b[39m \u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33mVersion of the provided Firefly server \u001b[39m\u001b[38;5;132;01m{\u001b[39;00murl\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m is not compatible with this version of firefly_client.\u001b[39m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m'\u001b[39m\n\u001b[32m 274\u001b[39m \u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33m Server version: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mver[\u001b[33m\"\u001b[39m\u001b[33mserver_version\u001b[39m\u001b[33m\"\u001b[39m]\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m'\u001b[39m\n\u001b[32m 275\u001b[39m \u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33m Required: >=\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mMIN_SERVER_VERSION\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m'\u001b[39m\n\u001b[32m 276\u001b[39m \u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33m Please use the URL of a compatible Firefly server\u001b[39m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m'\u001b[39m\n\u001b[32m 277\u001b[39m )\n\u001b[32m 279\u001b[39m debug(\u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33mnew instance: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00murl\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m'\u001b[39m)\n", + "\u001b[31mValueError\u001b[39m: Version of the provided Firefly server http://localhost:8080/firefly/ is not compatible with this version of firefly_client.\n Server version: 2025.3.2\n Required: >=2025.4\n Please use the URL of a compatible Firefly server\n" + ] + } + ], + "source": [ + "# Patch _confirm_version at class level so the mock takes effect inside make_client()\n", + "with patch.object(FireflyClient, '_confirm_version', return_value=ver_incompat):\n", + " FireflyClient.make_client(url='http://localhost:8080/firefly/', launch_browser=False)" + ] + }, + { + "cell_type": "markdown", + "id": "7eaed749", + "metadata": {}, + "source": [ + "`_confirm_version()` should return `compatible=False` and the old version string." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "a4000002", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "compatible: False\n", + "server_version: '2025.3.2'\n", + "raw response: {'success': True, 'data': {'Firefly Version': '2025.3.2'}}\n" + ] + } + ], + "source": [ + "pprint_confirm_version(ver_incompat)" + ] + }, + { + "cell_type": "markdown", + "id": "a5000001", + "metadata": {}, + "source": [ + "## Version unknown\n", + "\n", + "When the version endpoint returns `success: false`, is unreachable, or the version string is absent from the response, `_confirm_version()` falls back to `compatible=True` to preserve backward compatibility with servers that predate the version endpoint. Creating a FireflyClient instance should succeed." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "a5000004", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DEBUG: new instance: https://irsa.ipac.caltech.edu/irsaviewer/\n" + ] + } + ], + "source": [ + "fc_old = FireflyClient.make_client(url='https://irsa.ipac.caltech.edu/irsaviewer/', launch_browser=False)" + ] + }, + { + "cell_type": "markdown", + "id": "9a3c4582", + "metadata": {}, + "source": [ + "`_confirm_version()` should return `compatible=True` and `server_version=None`." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "8f14c22b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "compatible: True\n", + "server_version: None\n", + "raw response: {'success': False, 'error': {}}\n" + ] + } + ], + "source": [ + "pprint_confirm_version(fc_old._confirm_version())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f6fda662", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "ffpy", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/firefly_client/_server_compat.py b/firefly_client/_server_compat.py new file mode 100644 index 0000000..d479216 --- /dev/null +++ b/firefly_client/_server_compat.py @@ -0,0 +1,36 @@ +# Minimum Firefly server version this firefly_client is compatible with. +# Bump this only when a client release depends on a server-side change that +# breaks client functionality without it. Behavioral improvements don't count. +# +# Recent PR dependency log — explaining the dependency and whether it is an API break: +# firefly_client#79 → firefly#1936 (2026.1): _confirm_version() needs version endpoint [still works without it] +# firefly_client#78 → firefly#1910 (2026.1): show_xyplot/chart() needs activating chart view [still works without it] +# firefly_client#75 → firefly#1825 (2025.4): show_data() needs external upload action [fails without it] ← CURRENT +MIN_SERVER_VERSION = '2025.4' + +FIREFLY_VERSION_KEY = 'Firefly Version' + + +def _parse_version(version_str: str) -> tuple[int, int] | None: + """Extract (major, minor) from a Firefly version string, ignoring DEV/PRE/patch suffixes. + Returns None if not parseable. + """ + try: + core = version_str.split('-')[0] # strip DEV/PRE/patch suffix + parts = core.split('.') + return (int(parts[0]), int(parts[1])) + except (ValueError, IndexError): + return None + + +def is_server_compatible(server_version: str | None) -> bool: + if not server_version: + return True # unknown version — pass through for backward compatibility + + parsed_server_version = _parse_version(server_version) + if parsed_server_version is None: + return True # unparseable version — pass through + + # Python tuples are compared lexicographically (element-by-element), so (2026, 1) >= (2025, 4) + # evaluates as: 2026 > 2025 → True, without needing to inspect the minor at all + return parsed_server_version >= _parse_version(MIN_SERVER_VERSION) diff --git a/firefly_client/firefly_client.py b/firefly_client/firefly_client.py index 549f8b4..fad1378 100644 --- a/firefly_client/firefly_client.py +++ b/firefly_client/firefly_client.py @@ -36,6 +36,10 @@ except ImportError: from fc_utils import debug, warn, dict_to_str, create_image_url, ensure3, gen_item_id,\ DebugMarker, ALL, ACTION_DICT, LO_VIEW_DICT +try: + from ._server_compat import MIN_SERVER_VERSION, FIREFLY_VERSION_KEY, is_server_compatible +except ImportError: + from _server_compat import MIN_SERVER_VERSION, FIREFLY_VERSION_KEY, is_server_compatible __docformat__ = 'restructuredtext' _def_html_file = Env.find_default_firefly_html() @@ -238,8 +242,8 @@ def __init__(self, url, channel, html_file=_def_html_file, token=None, viewer_ov # urls for cmd service and browser protocol = 'https' if ssl else 'http' - self.url_cmd_service = urljoin('{}://{}/'.format(protocol, self.location), 'sticky/CmdSrv') - self.url_browser = urljoin(urljoin('{}://{}/'.format(protocol, self.location), html_file), '?__wsch=') + self.url_cmd_service = urljoin(f'{protocol}://{self.location}/', 'CmdSrv/sync') + self.url_browser = urljoin(urljoin(f'{protocol}://{self.location}/', html_file), '?__wsch=') self.url_bw = self.url_browser # keep around for backward compatibility self.session = requests.Session() @@ -262,6 +266,16 @@ def __init__(self, url, channel, html_file=_def_html_file, token=None, viewer_ov 'the `token` parameter must be passed.' ) raise ValueError(f'{url_err_msg}\n\n{token_err_msg}') + + ver = self._confirm_version() + if not ver['compatible']: + raise ValueError( + f'Version of the provided Firefly server {url} is not compatible with this version of firefly_client.\n' + f' Server version: {ver["server_version"]}\n' + f' Required: >={MIN_SERVER_VERSION}\n' + f' Please use the URL of a compatible Firefly server\n' + ) + debug(f'new instance: {url}') def _lab_env_tab_start(self, tab_type, html_file): @@ -303,6 +317,26 @@ def confirm_access(url, token=None): response = requests.get(healthz_url, headers=headers, allow_redirects=False) return {'success': response.status_code == 200, 'response': response} + def _confirm_version(self): + version_url = f'{self.url_cmd_service}?cmd=CmdVersion' + server_response = self.session.get(version_url, headers=self.header_from_ws) + + server_version = None + compatible = True # to preserve backward compatibility with servers that don't have version_url + + if server_response.status_code == 200: + payload = server_response.json() + if payload.get('success'): + version_data = payload.get('data', {}) + server_version = version_data.get(FIREFLY_VERSION_KEY) + compatible = is_server_compatible(server_version) + + return { + 'compatible': compatible, + 'server_version': server_version, + 'response': server_response, + } + def _send_url_as_get(self, url): return self.call_response(self.session.get(url, headers=self.header_from_ws)) diff --git a/pyproject.toml b/pyproject.toml index 57ddb0b..40d655a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ license = {file = "License.txt"} requires-python = ">=3.10" dependencies = [ "websocket-client", - "requests" + "requests", ] keywords = [ "jupyter", @@ -44,6 +44,9 @@ Documentation = "https://caltech-ipac.github.io/firefly_client" Repository = "http://github.com/Caltech-IPAC/firefly_client.git" [project.optional-dependencies] +tests = [ + "pytest", +] docs = [ "Sphinx>=7.3,<8.0", "sphinx-automodapi", diff --git a/tests/test_server_compat.py b/tests/test_server_compat.py new file mode 100644 index 0000000..832bb86 --- /dev/null +++ b/tests/test_server_compat.py @@ -0,0 +1,32 @@ +import pytest +from unittest.mock import patch +from firefly_client._server_compat import is_server_compatible + + +# Locked minimum version for test_is_server_compatible since test cases are based on this +_FIXED_MIN_VERSION = '2026.1' + + +# Read each row as: is_server_compatible(ver) for MIN=2026.1 → expected +@pytest.mark.parametrize('ver, expected', [ + # All variants within the 2026.1 cycle — DEV/PRE/patch all strip to (2026, 1) + ('2026.1', True), # clean + ('2026.1-DEV', True), # DEV suffix stripped + ('2026.1-DEV:branch_abc', True), # branch:commit stripped + ('2026.1-PRE', True), # PRE stripped + ('2026.1-PRE-3', True), # PRE with number stripped + ('2026.1.2', True), # patch digit ignored + # Newer cycles + ('2026.2', True), # newer minor + ('2027.1', True), # newer major + # Older cycles + ('2025.6', False), # older cycle + ('2025.6-PRE-3', False), # older cycle, PRE stripped + ('2024.1-DEV_abc1', False), # older cycle, DEV stripped + # Unknown/unparseable — pass through + ('not_a_version', True), # unparseable → None → pass through + (None, True), # None → pass through +]) +def test_is_server_compatible(ver, expected): + with patch('firefly_client._server_compat.MIN_SERVER_VERSION', _FIXED_MIN_VERSION): + assert is_server_compatible(ver) == expected