Working version of tabsearch. Added subsequent and fuzzy matching. Need to fix the unit tests
This commit is contained in:
1
Makefile
1
Makefile
@@ -25,3 +25,4 @@ clean: clean-build clean-tests
|
|||||||
clean-all : clean
|
clean-all : clean
|
||||||
rm -rf src/.sesskey
|
rm -rf src/.sesskey
|
||||||
rm -rf src/Users.db
|
rm -rf src/Users.db
|
||||||
|
rm -rf src/.myFastHtmlDb
|
||||||
|
|||||||
@@ -383,10 +383,6 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: 2px solid black;
|
|
||||||
/*background-color: var(--color-base-100);*/
|
|
||||||
/*padding: 1rem;*/
|
|
||||||
/*border-top: 1px solid var(--color-border-primary);*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mf-tab-content-wrapper {
|
.mf-tab-content-wrapper {
|
||||||
@@ -410,4 +406,10 @@
|
|||||||
.mf-vis {
|
.mf-vis {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-search-results {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ Note: `python-jose` may already be installed if you have FastAPI.
|
|||||||
### 1. Update API configuration in `auth/utils.py`
|
### 1. Update API configuration in `auth/utils.py`
|
||||||
|
|
||||||
```python
|
```python
|
||||||
API_BASE_URL = "http://localhost:5001" # Your FastAPI backend URL
|
API_BASE_URL = "http://localhost:5003" # Your FastAPI backend URL
|
||||||
JWT_SECRET = "jwt-secret-to-change" # Must match your FastAPI secret
|
JWT_SECRET = "jwt-secret-to-change" # Must match your FastAPI secret
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from typing import Callable, Any
|
||||||
|
|
||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
@@ -6,43 +7,78 @@ from myfasthtml.controls.BaseCommands import BaseCommands
|
|||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import Ids, mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.instances import MultipleInstance
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
from myfasthtml.core.matching_utils import subsequence_matching, fuzzy_matching
|
||||||
|
|
||||||
logger = logging.getLogger("Search")
|
logger = logging.getLogger("Search")
|
||||||
|
|
||||||
|
|
||||||
class Commands(BaseCommands):
|
class Commands(BaseCommands):
|
||||||
def search(self):
|
def search(self):
|
||||||
return (Command("Search", f"Search {self._owner.items_names}", self._owner.search).
|
return (Command("Search", f"Search {self._owner.items_names}", self._owner.on_search).
|
||||||
htmx(trigger="keyup changed delay:300ms"))
|
htmx(target=f"#{self._owner.get_id()}-results",
|
||||||
|
trigger="keyup changed delay:300ms",
|
||||||
|
swap="innerHTML"))
|
||||||
|
|
||||||
|
|
||||||
class Search(MultipleInstance):
|
class Search(MultipleInstance):
|
||||||
def __init__(self, session, items_names=None, template=None, _id=None):
|
def __init__(self,
|
||||||
|
session,
|
||||||
|
_id=None,
|
||||||
|
items_names=None, # what is the name of the items to filter
|
||||||
|
items=None, # first set of items to filter
|
||||||
|
get_attr: Callable[[Any], str] = None, # items is a list of objects: how to get the str to filter
|
||||||
|
template: Callable[[Any], Any] = None): # once filtered, what to render ?
|
||||||
|
"""
|
||||||
|
Represents a component for managing and filtering a list of items based on specific criteria.
|
||||||
|
|
||||||
|
This class initializes with a session, an optional identifier, a list of item names,
|
||||||
|
a callable for extracting a string value from items, and a template callable for rendering
|
||||||
|
the filtered items. It provides functionality to handle and organize item-based operations.
|
||||||
|
|
||||||
|
:param session: The session object to maintain state or context across operations.
|
||||||
|
:param _id: Optional identifier for the component.
|
||||||
|
:param items: An optional list of names for the items to be filtered.
|
||||||
|
:param get_attr: Callable function to extract a string value from an item for filtering. Defaults to a
|
||||||
|
function that returns the item as is.
|
||||||
|
:param template: Callable function to render the filtered items. Defaults to a Div rendering function.
|
||||||
|
"""
|
||||||
super().__init__(session, Ids.Search, _id=_id)
|
super().__init__(session, Ids.Search, _id=_id)
|
||||||
self.items_names = items_names
|
self.items_names = items_names or ''
|
||||||
self.items = []
|
self.items = items or []
|
||||||
self.filtered = []
|
self.filtered = self.items.copy()
|
||||||
|
self.get_attr = get_attr or (lambda x: x)
|
||||||
self.template = template or Div
|
self.template = template or Div
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
|
|
||||||
def set_items(self, items):
|
def set_items(self, items):
|
||||||
self.items = items
|
self.items = items
|
||||||
self.filtered = self.items
|
self.filtered = self.items.copy()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def on_search(self, query):
|
||||||
|
logger.debug(f"on_search {query=}")
|
||||||
|
self.search(query)
|
||||||
|
return tuple(self._mk_search_results())
|
||||||
|
|
||||||
def search(self, query):
|
def search(self, query):
|
||||||
logger.debug(f"search {query=}")
|
logger.debug(f"search {query=}")
|
||||||
pass
|
if query is None or query.strip() == "":
|
||||||
|
self.filtered = self.items.copy()
|
||||||
|
|
||||||
|
else:
|
||||||
|
res_seq = subsequence_matching(query, self.items, get_attr=self.get_attr)
|
||||||
|
res_fuzzy = fuzzy_matching(query, self.items, get_attr=self.get_attr)
|
||||||
|
self.filtered = res_seq + res_fuzzy
|
||||||
|
|
||||||
|
return self.filtered
|
||||||
|
|
||||||
def _mk_search_results(self):
|
def _mk_search_results(self):
|
||||||
return [self.template(item) for item in self.filtered]
|
return [self.template(item) for item in self.filtered]
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
return Div(
|
return Div(
|
||||||
# mk.mk(Input(name="query", id=f"{self._id}-search", type="text", placeholder="Search...", cls="input input-xs"),
|
mk.mk(Input(name="query", id=f"{self._id}-search", type="text", placeholder="Search...", cls="input input-xs"),
|
||||||
# command=self.commands.search()),
|
command=self.commands.search()),
|
||||||
mk.mk(Input(name="query", id=f"{self._id}-search", type="search", placeholder="Search...", cls="input input-xs"),
|
|
||||||
),
|
|
||||||
Div(
|
Div(
|
||||||
*self._mk_search_results(),
|
*self._mk_search_results(),
|
||||||
id=f"{self._id}-results",
|
id=f"{self._id}-results",
|
||||||
|
|||||||
@@ -77,11 +77,6 @@ class Commands(BaseCommands):
|
|||||||
return Command(f"{self._prefix}UpdateBoundaries",
|
return Command(f"{self._prefix}UpdateBoundaries",
|
||||||
"Update component boundaries",
|
"Update component boundaries",
|
||||||
self._owner.update_boundaries).htmx(target=None)
|
self._owner.update_boundaries).htmx(target=None)
|
||||||
|
|
||||||
def search_tab(self, query: str):
|
|
||||||
return Command(f"{self._prefix}SearchTab",
|
|
||||||
"Search for a tab by name",
|
|
||||||
self._owner.search_tab, query).htmx(target=f"#{self._id}-dropdown-content")
|
|
||||||
|
|
||||||
|
|
||||||
class TabsManager(MultipleInstance):
|
class TabsManager(MultipleInstance):
|
||||||
@@ -97,7 +92,10 @@ class TabsManager(MultipleInstance):
|
|||||||
self._state = TabsManagerState(self)
|
self._state = TabsManagerState(self)
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self._boundaries = Boundaries()
|
self._boundaries = Boundaries()
|
||||||
self._search = Search(self._session).set_items(self._get_tabs_labels())
|
self._search = Search(self._session,
|
||||||
|
items=self._get_tab_list(),
|
||||||
|
get_attr=lambda x: x["label"],
|
||||||
|
template=self._mk_tab_button)
|
||||||
logger.debug(f"TabsManager created with id: {self._id}")
|
logger.debug(f"TabsManager created with id: {self._id}")
|
||||||
logger.debug(f" tabs : {self._get_ordered_tabs()}")
|
logger.debug(f" tabs : {self._get_ordered_tabs()}")
|
||||||
logger.debug(f" active tab : {self._state.active_tab}")
|
logger.debug(f" active tab : {self._state.active_tab}")
|
||||||
@@ -176,6 +174,7 @@ class TabsManager(MultipleInstance):
|
|||||||
|
|
||||||
# Add tab metadata to state
|
# Add tab metadata to state
|
||||||
state.tabs[tab_id] = {
|
state.tabs[tab_id] = {
|
||||||
|
'id': tab_id,
|
||||||
'label': label,
|
'label': label,
|
||||||
'component_type': component_type,
|
'component_type': component_type,
|
||||||
'component_id': component_id
|
'component_id': component_id
|
||||||
@@ -193,7 +192,7 @@ class TabsManager(MultipleInstance):
|
|||||||
|
|
||||||
# finally, update the state
|
# finally, update the state
|
||||||
self._state.update(state)
|
self._state.update(state)
|
||||||
self._search.set_items(self._get_tabs_labels())
|
self._search.set_items(self._get_tab_list())
|
||||||
|
|
||||||
return tab_id
|
return tab_id
|
||||||
|
|
||||||
@@ -251,23 +250,10 @@ class TabsManager(MultipleInstance):
|
|||||||
|
|
||||||
# Update state
|
# Update state
|
||||||
self._state.update(state)
|
self._state.update(state)
|
||||||
self._search.set_items(self._get_tabs_labels())
|
self._search.set_items(self._get_tab_list())
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def search_tab(self, query: str):
|
|
||||||
"""
|
|
||||||
Search tabs by name (for dropdown search).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dropdown content with filtered tabs
|
|
||||||
"""
|
|
||||||
# This will be implemented later for search functionality
|
|
||||||
pass
|
|
||||||
|
|
||||||
def add_tab_btn(self):
|
def add_tab_btn(self):
|
||||||
return mk.icon(tab_add24_regular,
|
return mk.icon(tab_add24_regular,
|
||||||
id=f"{self._id}-add-tab-btn",
|
id=f"{self._id}-add-tab-btn",
|
||||||
@@ -285,7 +271,7 @@ class TabsManager(MultipleInstance):
|
|||||||
def _mk_tabs_header(self, oob=False):
|
def _mk_tabs_header(self, oob=False):
|
||||||
# Create visible tab buttons
|
# Create visible tab buttons
|
||||||
visible_tab_buttons = [
|
visible_tab_buttons = [
|
||||||
self._mk_tab_button(tab_id, self._state.tabs[tab_id])
|
self._mk_tab_button(self._state.tabs[tab_id])
|
||||||
for tab_id in self._state.tabs_order
|
for tab_id in self._state.tabs_order
|
||||||
if tab_id in self._state.tabs
|
if tab_id in self._state.tabs
|
||||||
]
|
]
|
||||||
@@ -300,7 +286,7 @@ class TabsManager(MultipleInstance):
|
|||||||
hx_swap_oob="true" if oob else None
|
hx_swap_oob="true" if oob else None
|
||||||
)
|
)
|
||||||
|
|
||||||
def _mk_tab_button(self, tab_id: str, tab_data: dict, in_dropdown: bool = False):
|
def _mk_tab_button(self, tab_data: dict, in_dropdown: bool = False):
|
||||||
"""
|
"""
|
||||||
Create a single tab button with its label and close button.
|
Create a single tab button with its label and close button.
|
||||||
|
|
||||||
@@ -312,6 +298,7 @@ class TabsManager(MultipleInstance):
|
|||||||
Returns:
|
Returns:
|
||||||
Button element representing the tab
|
Button element representing the tab
|
||||||
"""
|
"""
|
||||||
|
tab_id = tab_data["id"]
|
||||||
is_active = tab_id == self._state.active_tab
|
is_active = tab_id == self._state.active_tab
|
||||||
|
|
||||||
close_btn = mk.mk(
|
close_btn = mk.mk(
|
||||||
@@ -382,8 +369,8 @@ class TabsManager(MultipleInstance):
|
|||||||
hx_swap_oob=f"beforeend:#{self._id}-content-wrapper",
|
hx_swap_oob=f"beforeend:#{self._id}-content-wrapper",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_tabs_labels(self):
|
def _get_tab_list(self):
|
||||||
return [tab["label"] for tab in self._state.tabs.values()]
|
return [self._state.tabs[tab_id] for tab_id in self._state.tabs_order if tab_id in self._state.tabs]
|
||||||
|
|
||||||
def update_boundaries(self):
|
def update_boundaries(self):
|
||||||
return Script(f"updateBoundaries('{self._id}');")
|
return Script(f"updateBoundaries('{self._id}');")
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ class BaseCommand:
|
|||||||
def execute(self, client_response: dict = None):
|
def execute(self, client_response: dict = None):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def htmx(self, target="this", swap="innerHTML", trigger=None):
|
def htmx(self, target="this", swap="outerHTML", trigger=None):
|
||||||
|
# Note that the default value is the same than in get_htmx_params()
|
||||||
if target is None:
|
if target is None:
|
||||||
self._htmx_extra["hx-swap"] = "none"
|
self._htmx_extra["hx-swap"] = "none"
|
||||||
elif target != "this":
|
elif target != "this":
|
||||||
@@ -52,7 +53,7 @@ class BaseCommand:
|
|||||||
|
|
||||||
if swap is None:
|
if swap is None:
|
||||||
self._htmx_extra["hx-swap"] = "none"
|
self._htmx_extra["hx-swap"] = "none"
|
||||||
elif swap != "innerHTML":
|
elif swap != "outerHTML":
|
||||||
self._htmx_extra["hx-swap"] = swap
|
self._htmx_extra["hx-swap"] = swap
|
||||||
|
|
||||||
if trigger is not None:
|
if trigger is not None:
|
||||||
@@ -162,7 +163,7 @@ class Command(BaseCommand):
|
|||||||
# Set the hx-swap-oob attribute on all elements returned by the callback
|
# Set the hx-swap-oob attribute on all elements returned by the callback
|
||||||
if isinstance(ret, (list, tuple)):
|
if isinstance(ret, (list, tuple)):
|
||||||
for r in ret[1:]:
|
for r in ret[1:]:
|
||||||
if hasattr(r, 'attrs'):
|
if hasattr(r, 'attrs') and r.get("id", None) is not None:
|
||||||
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true")
|
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true")
|
||||||
|
|
||||||
if not ret_from_bindings:
|
if not ret_from_bindings:
|
||||||
|
|||||||
85
src/myfasthtml/core/matching_utils.py
Normal file
85
src/myfasthtml/core/matching_utils.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from difflib import SequenceMatcher
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _is_subsequence(query: str, target: str) -> tuple[bool, float]:
|
||||||
|
"""
|
||||||
|
Determines if a query string is a subsequence of a target string and calculates
|
||||||
|
a score based on the compactness of the match. The match is case-insensitive.
|
||||||
|
|
||||||
|
The function iterates through each character of the query and checks if it
|
||||||
|
exists in the target string while maintaining the order. If all characters of
|
||||||
|
the query are found in order, it calculates a score based on the smallest
|
||||||
|
window in the target that contains all the matched characters.
|
||||||
|
|
||||||
|
:param query: The query string to check as a subsequence.
|
||||||
|
:param target: The target string in which to find the subsequence.
|
||||||
|
:return: A tuple where the first value is a boolean indicating if a valid
|
||||||
|
subsequence exists, and the second value is a float representing the
|
||||||
|
compactness score of the match.
|
||||||
|
:rtype: tuple[bool, float]
|
||||||
|
"""
|
||||||
|
query = query.lower()
|
||||||
|
target = target.lower()
|
||||||
|
|
||||||
|
positions = []
|
||||||
|
idx = 0
|
||||||
|
|
||||||
|
for char in query:
|
||||||
|
idx = target.find(char, idx)
|
||||||
|
if idx == -1:
|
||||||
|
return False, 0.0
|
||||||
|
positions.append(idx)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
# Smallest window containing all matched chars
|
||||||
|
window_size = positions[-1] - positions[0] + 1
|
||||||
|
|
||||||
|
# Score: ratio of query length vs window size (compactness)
|
||||||
|
score = len(query) / window_size
|
||||||
|
|
||||||
|
return True, score
|
||||||
|
|
||||||
|
|
||||||
|
def fuzzy_matching(query: str, choices: list[Any], similarity_threshold: float = 0.7, get_attr=None):
|
||||||
|
"""
|
||||||
|
Perform fuzzy matching on a list of items to find the items that are similar
|
||||||
|
to the given query based on a similarity threshold.
|
||||||
|
|
||||||
|
:param query: The search query to be matched, provided as a string.
|
||||||
|
:param choices: A list of strings representing the items to be compared against the query.
|
||||||
|
:param similarity_threshold: A float value representing the minimum similarity score
|
||||||
|
(between 0 and 1) an item needs to achieve to be considered a match. Defaults to 0.7.
|
||||||
|
:param get_attr: When choice is a object, give the property to use
|
||||||
|
:return: A list of strings containing the items from the input list that meet or exceed
|
||||||
|
the similarity threshold, sorted in descending order of similarity.
|
||||||
|
"""
|
||||||
|
get_attr = get_attr or (lambda x: x)
|
||||||
|
matches = []
|
||||||
|
for file_doc in choices:
|
||||||
|
# Calculate similarity between search term and filename
|
||||||
|
similarity = SequenceMatcher(None, query.lower(), get_attr(file_doc).lower()).ratio()
|
||||||
|
|
||||||
|
if similarity >= similarity_threshold:
|
||||||
|
matches.append((file_doc, similarity))
|
||||||
|
|
||||||
|
# Sort by similarity score (highest first)
|
||||||
|
matches.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
|
||||||
|
# Return only the FileDocument objects
|
||||||
|
return [match[0] for match in matches]
|
||||||
|
|
||||||
|
|
||||||
|
def subsequence_matching(query: str, choices: list[Any], get_attr=None):
|
||||||
|
get_attr = get_attr or (lambda x: x)
|
||||||
|
matches = []
|
||||||
|
for item in choices:
|
||||||
|
matched, score = _is_subsequence(query, get_attr(item))
|
||||||
|
if matched:
|
||||||
|
matches.append((item, score))
|
||||||
|
|
||||||
|
# Sort by score (highest first)
|
||||||
|
matches.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
|
||||||
|
# Return only the FileDocument objects
|
||||||
|
return [match[0] for match in matches]
|
||||||
@@ -104,8 +104,8 @@ class TestCommandBind:
|
|||||||
assert matches(updated, expected)
|
assert matches(updated, expected)
|
||||||
|
|
||||||
@pytest.mark.parametrize("return_values", [
|
@pytest.mark.parametrize("return_values", [
|
||||||
[Div(), Div(), "hello", Div()], # list
|
[Div(), Div(id="id1"), "hello", Div(id="id2")], # list
|
||||||
(Div(), Div(), "hello", Div()) # tuple
|
(Div(), Div(id="id1"), "hello", Div(id="id2")) # tuple
|
||||||
])
|
])
|
||||||
def test_swap_oob_is_automatically_set_when_multiple_elements_are_returned(self, return_values):
|
def test_swap_oob_is_automatically_set_when_multiple_elements_are_returned(self, return_values):
|
||||||
"""Test that hx-swap-oob is automatically set, but not for the first."""
|
"""Test that hx-swap-oob is automatically set, but not for the first."""
|
||||||
@@ -157,3 +157,29 @@ class TestCommandExecute:
|
|||||||
|
|
||||||
command = Command('test', 'Command description', callback_with_param)
|
command = Command('test', 'Command description', callback_with_param)
|
||||||
command.execute(client_response={"number": "10"})
|
command.execute(client_response={"number": "10"})
|
||||||
|
|
||||||
|
def test_swap_oob_is_added_when_multiple_elements_are_returned(self):
|
||||||
|
"""Test that hx-swap-oob is automatically set, but not for the first."""
|
||||||
|
|
||||||
|
def another_callback():
|
||||||
|
return Div(id="first"), Div(id="second"), "hello", Div(id="third")
|
||||||
|
|
||||||
|
command = Command('test', 'Command description', another_callback)
|
||||||
|
|
||||||
|
res = command.execute()
|
||||||
|
assert "hx-swap-oob" not in res[0].attrs
|
||||||
|
assert res[1].attrs["hx-swap-oob"] == "true"
|
||||||
|
assert res[3].attrs["hx-swap-oob"] == "true"
|
||||||
|
|
||||||
|
def test_swap_oob_is_not_added_when_there_no_id(self):
|
||||||
|
"""Test that hx-swap-oob is automatically set, but not for the first."""
|
||||||
|
|
||||||
|
def another_callback():
|
||||||
|
return Div(id="first"), Div(), "hello", Div()
|
||||||
|
|
||||||
|
command = Command('test', 'Command description', another_callback)
|
||||||
|
|
||||||
|
res = command.execute()
|
||||||
|
assert "hx-swap-oob" not in res[0].attrs
|
||||||
|
assert "hx-swap-oob" not in res[1].attrs
|
||||||
|
assert "hx-swap-oob" not in res[3].attrs
|
||||||
|
|||||||
105
tests/core/test_matching_utils.py
Normal file
105
tests/core/test_matching_utils.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from myfasthtml.core.matching_utils import fuzzy_matching, subsequence_matching
|
||||||
|
|
||||||
|
|
||||||
|
class TestFuzzyMatching:
|
||||||
|
def test_i_can_find_exact_match_with_fuzzy(self):
|
||||||
|
# Exact match should always pass
|
||||||
|
choices = ["hello"]
|
||||||
|
result = fuzzy_matching("hello", choices)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0] == "hello"
|
||||||
|
|
||||||
|
def test_i_can_find_close_match_with_fuzzy(self):
|
||||||
|
# "helo.txt" should match "hello.txt" with high similarity
|
||||||
|
choices = ["hello"]
|
||||||
|
result = fuzzy_matching("helo", choices, similarity_threshold=0.7)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0] == "hello"
|
||||||
|
|
||||||
|
def test_i_cannot_find_dissimilar_match_with_fuzzy(self):
|
||||||
|
# "world.txt" should not match "hello.txt"
|
||||||
|
choices = ["hello"]
|
||||||
|
result = fuzzy_matching("world", choices, similarity_threshold=0.7)
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
def test_i_can_sort_by_similarity_in_fuzzy(self):
|
||||||
|
# hello has a higher similarity than helo
|
||||||
|
choices = [
|
||||||
|
"hello",
|
||||||
|
"helo",
|
||||||
|
]
|
||||||
|
result = fuzzy_matching("hello", choices, similarity_threshold=0.7)
|
||||||
|
assert result == ["hello", "helo"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_find_on_object(self):
|
||||||
|
@dataclass
|
||||||
|
class DummyObject:
|
||||||
|
value: str
|
||||||
|
id: str
|
||||||
|
|
||||||
|
choices = [
|
||||||
|
DummyObject("helo", "1"),
|
||||||
|
DummyObject("hello", "2"),
|
||||||
|
DummyObject("xyz", "3"),
|
||||||
|
]
|
||||||
|
result = fuzzy_matching("hello", choices, get_attr=lambda x: x.value)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result == [DummyObject("hello", "2"), DummyObject("helo", "1")]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubsequenceMatching:
|
||||||
|
def test_i_can_match_subsequence_simple(self):
|
||||||
|
# "abg" should match "AlphaBetaGamma"
|
||||||
|
choices = ["AlphaBetaGamma"]
|
||||||
|
result = subsequence_matching("abg", choices)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0] == "AlphaBetaGamma"
|
||||||
|
|
||||||
|
def test_i_can_match_subsequence_simple_case_insensitive(self):
|
||||||
|
# "abg" should match "alphabetagamma"
|
||||||
|
choices = ["alphabetagamma"]
|
||||||
|
result = subsequence_matching("abg", choices)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0] == "alphabetagamma"
|
||||||
|
|
||||||
|
def test_i_cannot_match_wrong_order_subsequence(self):
|
||||||
|
# the order is wrong
|
||||||
|
choices = ["AlphaBetaGamma"]
|
||||||
|
result = subsequence_matching("gba", choices)
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
def test_i_can_match_multiple_documents_subsequence(self):
|
||||||
|
# "abg" should match both filenames, but "AlphaBetaGamma" has a higher score
|
||||||
|
choices = [
|
||||||
|
"AlphaBetaGamma",
|
||||||
|
"HalleBerryIsGone",
|
||||||
|
]
|
||||||
|
result = subsequence_matching("abg", choices)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0] == "AlphaBetaGamma"
|
||||||
|
assert result[1] == "HalleBerryIsGone"
|
||||||
|
|
||||||
|
def test_i_cannot_match_unrelated_subsequence(self):
|
||||||
|
# "xyz" should not match any file
|
||||||
|
choices = ["AlphaBetaGamma"]
|
||||||
|
result = subsequence_matching("xyz", choices)
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
def test_i_can_match_on_object(self):
|
||||||
|
@dataclass
|
||||||
|
class DummyObject:
|
||||||
|
value: str
|
||||||
|
id: str
|
||||||
|
|
||||||
|
choices = [
|
||||||
|
DummyObject("HalleBerryIsGone", "1"),
|
||||||
|
DummyObject("AlphaBetaGamma", "2"),
|
||||||
|
DummyObject("xyz", "3"),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subsequence_matching("abg", choices, get_attr=lambda x: x.value)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result == [DummyObject("AlphaBetaGamma", "2"), DummyObject("HalleBerryIsGone", "1")]
|
||||||
Reference in New Issue
Block a user