First Working version. I can add table

This commit is contained in:
2025-05-10 16:55:52 +02:00
parent b708ef2c46
commit 2daff83e67
157 changed files with 17282 additions and 12 deletions

66
tests/conftest.py Normal file
View File

@@ -0,0 +1,66 @@
from io import BytesIO
import pandas as pd
import pytest
from components.datagrid.DataGrid import reset_instances
USER_EMAIL = "test@mail.com"
USER_ID = "test_user"
@pytest.fixture
def excel_file_content():
# Create a simple Excel file in memory
df = pd.DataFrame({
'Column 1': ['Aba', 'Johan', 'Kodjo'],
'Column 2': ['Female', 'Male', 'Male']
})
excel_io = BytesIO()
df.to_excel(excel_io, index=False)
excel_io.seek(0)
return excel_io.read()
@pytest.fixture
def excel_file_content_2():
# Create a simple Excel file in memory
df = pd.DataFrame({
'Column 1': ['C', 'A', 'B'],
'Column 2': [1, 2, 3]
})
excel_io = BytesIO()
df.to_excel(excel_io, index=False)
excel_io.seek(0)
return excel_io.read()
@pytest.fixture
def excel_file_content_with_sheet_name():
# Create a DataFrame
df = pd.DataFrame({
'Column 1 ': ['Aba', 'Johan', 'Kodjo'],
'Column 2': [False, True, True],
'Column 3 ': [10, 20, 30],
})
# Create an in-memory bytes buffer
excel_io = BytesIO()
# Write the dataframe to the buffer with a custom sheet name
df.to_excel(excel_io, index=False, sheet_name="sheet_name")
# Move the pointer to the start of the stream
excel_io.seek(0)
return excel_io.read() # Return the binary data
@pytest.fixture(autouse=True)
def reset_datagrid_instances():
reset_instances()
@pytest.fixture
def session():
return {"user_id": USER_ID, "user_email": USER_EMAIL}

BIN
tests/fixtures/Book1.xlsx vendored Normal file

Binary file not shown.

549
tests/helpers.py Normal file
View File

@@ -0,0 +1,549 @@
import dataclasses
import json
import re
from collections import OrderedDict
import numpy
import pandas as pd
from bs4 import BeautifulSoup
from fastcore.basics import NotStr
from fastcore.xml import to_xml
from fasthtml.components import html2ft, Div
pattern = r"""(?P<tag>\w+)(?:#(?P<id>[\w-]+))?(?P<attributes>(?:\[\w+=['"]?[\w_-]+['"]?\])*)"""
attr_pattern = r"""\[(?P<name>\w+)=['"]?(?P<value>[\w_-]+)['"]?\]"""
svg_pattern = r"""svg name="(\w+)\""""
compiled_pattern = re.compile(pattern)
compiled_attr_pattern = re.compile(attr_pattern)
compiled_svg_pattern = re.compile(svg_pattern)
@dataclasses.dataclass
class DoNotCheck:
desc: str = None
class EmptyElement:
pass
@dataclasses.dataclass
class StartsWith:
"""
To check if the attribute starts with a specific value
"""
s: str
@dataclasses.dataclass
class Contains:
"""
To check if the attribute contains a specific value
"""
s: str
Empty = EmptyElement()
@dataclasses.dataclass
class HTMLElement:
tag: str
attrs: dict
children: list['HTMLElement'] = dataclasses.field(default_factory=list)
text: str | None = None
# Function to transform BeautifulSoup elements into the HTMLElement class
def parse_element(element) -> HTMLElement:
def _process_attributes(attrs):
return {key: ' '.join(value) if isinstance(value, list) else value for key, value in attrs.items()}
# Create an HTMLElement object for the current element
html_element = HTMLElement(
tag=element.name,
attrs=_process_attributes(element.attrs),
text=element.string if element.string else None
)
# Recursively parse and add child elements
for child in element.children:
if child.name is not None: # Only process tags, ignore NavigableStrings
html_element.children.append(parse_element(child))
return html_element
def get_from_html(html_str, path=None, attrs=None):
soup = BeautifulSoup(html_str, 'html.parser')
element = parse_element(soup)
return element if path is None else search_elements_by_path(element, path, attrs)[0]
def print_path(path):
return f"Path '{path}':\n\t" if path else ""
def get_path_attributes(path):
"""
Get the attributes from
div#id[attr1=value1][attr2=value2]
:param path:
:return:
"""
attrs = {}
match = compiled_pattern.match(path)
if match:
attrs['tag'] = match.group('tag')
if match.group('id'):
attrs['id'] = match.group('id')
attributes = match.group("attributes")
attr_matches = compiled_attr_pattern.findall(attributes)
for name, value in attr_matches:
attrs[name] = value
return attrs
def match_attrs(element_attrs, criteria_attrs):
if not criteria_attrs:
return True
return all(item in element_attrs.items() for item in criteria_attrs.items())
def contains_attrs(element_attrs, criteria_attrs):
if not criteria_attrs:
return True
return all(k in element_attrs and v in element_attrs[k] for k, v in criteria_attrs.items())
def search_elements_by_name(ft, tag: str = None, attrs: dict = None, comparison_method: str = "exact"):
"""
Select all elements that either match the tag and / or the attribute
:param ft:
:param tag:
:param attrs:
:param comparison_method: 'exact' or 'contains'
:return:
"""
compare_attrs = contains_attrs if comparison_method == "contains" else match_attrs
def _search_elements_by_name(_ft):
result = []
if isinstance(_ft, NotStr) and tag is not None and tag.lower() == "notstr":
result.append(_ft)
elif hasattr(_ft, "tag"):
# Base case: check if the current element matches the criteria
if (tag is None or _ft.tag == tag) and compare_attrs(_ft.attrs, attrs):
result.append(_ft)
# Recursive case: search through the children
for child in _ft.children:
result.extend(_search_elements_by_name(child))
return result
if isinstance(ft, list):
res = []
for item in ft:
res.extend(_search_elements_by_name(item))
return res if res else None
return _search_elements_by_name(ft)
def search_elements_by_path(ft, path: str, attrs: dict = None):
"""
Selects elements that match a given path. The path is a dot-separated list of elements.
One the path if found, the optional attributes are compared against the last element's
attributes.
Note the path may not start at the root node of the tree structure.
:param ft: The root node of the tree structure to search within.
:param path: Dot-separated string representing the path to match within the tree structure.
:param attrs: Optional dictionary of attributes to match against the tree nodes. If not
provided, no attribute filtering is applied.
:return: A list of nodes matching the given path and attributes.
"""
parts = path.split(".")
tail = parts.pop()
head = ".".join(parts)
def _find(current, previous_path):
result = []
if (current.tag == tail
and previous_path.endswith(head)
and match_attrs(current.attrs, attrs)):
result.append(current)
for child in current.children:
if hasattr(child, "tag"):
next_path = previous_path + "." + current.tag if previous_path else current.tag
result.extend(_find(child, next_path))
return result
return _find(ft, "")
def search_first_with_attribute(ft, tag, attribute
):
"""
Browse ft and its children to find the first element that matches the tag and has the attribute defined
if tag is None, it will return the first element with the attribute
:param ft:
:param tag:
:param attribute
:
:return:
"""
if attribute is None:
raise ValueError("Attribute must be provided to find an element.")
if not hasattr(ft, "tag"):
return None
# Check the current element
if (tag is None or ft.tag == tag) and attribute in ft.attrs:
return ft
# Traverse children if the current element doesn't match
for child in ft.children:
result = search_first_with_attribute(child, tag, attribute)
if result:
return result
return None
def find_first_match(ft, path: str):
"""
Use backtracking to find the first element that matches the full path
you can use #id and [attr=value] in the path
exemple : div#id[attr=value].div.span#id_2[class=class_2]
will return the span#id_2 element if it exists
:param ft:
:param path:
:return:
"""
def _matches(element, path_part):
"""Check if an element matches a specific path part."""
if not hasattr(element, "attrs"):
return False
attrs_to_match = get_path_attributes(path_part)
element_attrs = element.attrs.copy() | {"tag": element.tag}
return all(element_attrs.get(attr) == value for attr, value in attrs_to_match.items())
def _search(elements, path_parts):
"""Recursively search for the matching element."""
if not path_parts:
return None
for element in elements:
if _matches(element, path_parts[0]):
if len(path_parts) == 1:
return element
res = _search(element.children, path_parts[1:])
if res is not None:
return res
return None
elements_as_list = ft if isinstance(ft, (list, tuple)) else [ft]
return _search(elements_as_list, path.split("."))
def matches(actual, expected, path=""):
def _type(x):
if isinstance(x, numpy.int64):
return int
elif isinstance(x, numpy.float64):
return float
return type(x)
if actual is None and expected is not None:
assert False, f"{print_path(path)}actual is None !"
if isinstance(expected, DoNotCheck):
return True
if expected is Empty:
assert actual.attrs == {}, f"Empty element expected, but found attributes {actual.attrs}."
assert len(actual.children) == 0, f"Empty element expected, but found children {actual.children}."
return True
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
f"{print_path(path)}The types are different: {type(actual)} != {type(expected)}, ({actual} != {expected})."
if isinstance(expected, (list, tuple)):
assert len(actual) >= len(expected), \
f"{print_path(path)}Some required elements are missing: {actual} != {expected}."
for actual_child, expected_child in zip(actual, expected):
assert matches(actual_child, expected_child)
elif isinstance(expected, NotStr):
assert actual.s.lstrip('\n').startswith(expected.s), \
f"{print_path(path)}NotStr are different: '{actual.s.lstrip('\n')}' != '{expected.s}'."
elif hasattr(actual, "tag"):
assert actual.tag == expected.tag, \
f"{print_path(path)}The elements are different: '{actual.tag}' != '{expected.tag}'."
# tag are the same, I can update it and be up to date when attr comparison fails
path = path + "." + actual.tag if path else actual.tag
if "id" in actual.attrs:
path += f"#{actual.attrs['id']}"
elif "name" in actual.attrs:
path += f"[name={actual.attrs['name']}]"
elif "class" in actual.attrs:
path += f"[class={actual.attrs['class']}]"
# only test the attributes referenced by the expected
for expected_attr in expected.attrs:
assert expected_attr in actual.attrs, \
f"{print_path(path)}Attribute '{expected_attr}' is not found (with expected value: '{expected.attrs[expected_attr]}'). actual='{actual.attrs}'."
if isinstance(expected.attrs[expected_attr], StartsWith):
assert actual.attrs[expected_attr].startswith(expected.attrs[expected_attr].s), \
f"{print_path(path)}Attribute '{expected_attr}' does not start with '{expected.attrs[expected_attr].s}': actual='{actual.attrs[expected_attr]}', expected ='{expected.attrs[expected_attr].s}'."
elif isinstance(expected.attrs[expected_attr], Contains):
assert expected.attrs[expected_attr].s in actual.attrs[expected_attr], \
f"{print_path(path)}Attribute '{expected_attr}' does not contain '{expected.attrs[expected_attr].s}': actual='{actual.attrs[expected_attr]}', expected ='{expected.attrs[expected_attr].s}'."
else:
assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \
f"{print_path(path)}The values are different for '{expected_attr}' : '{actual.attrs[expected_attr]}' != '{expected.attrs[expected_attr]}'."
if len(expected.children) > 0 and expected.children[0] is Empty:
matches(actual, expected.children[0], path)
else:
# hack to manage ft and Html object different behaviour
if len(actual.children) == 0 and len(expected.children) == 1 and expected.children[0] == NotStr(""):
pass
else:
assert len(actual.children) >= len(expected.children), \
f"{print_path(path)}Some required elements are missing: actual={actual.children} != expected={expected.children}."
for actual_child, expected_child in zip(actual.children, expected.children):
matches(actual_child, expected_child, path)
else:
assert actual == expected, \
f"{print_path(path)}The values are not the same: '{actual}' != '{expected}'."
return True
def get_selected(return_elements):
assert isinstance(return_elements, list), "result must be a list"
for element in return_elements:
if hasattr(element, "id") and element.id.startswith("tsm_"):
break
else:
assert False, "No element with id 'tsm_' found in the return elements"
res = []
for child in element.children:
selection_type = child.attrs["selection-type"]
if selection_type.startswith("cell"):
split = child.attrs["element-id"].split("-")
selected = (selection_type, int(split[-2]), int(split[-1]))
elif selection_type == "row":
split = child.attrs["element-id"].split("-")
selected = ("row", int(split[-1]))
elif selection_type == "column":
element_id = child.attrs["element-id"]
selected = ("column", element_id)
else:
raise NotImplemented("")
res.append(selected)
return res
def get_context_menu(return_elements):
assert isinstance(return_elements, list), "result must be a list"
found = False
res = []
for element in return_elements:
if hasattr(element, "id") and element.id[:5] in ("cmcm_", "cmrm_"):
found = True
for child in element.children:
if "hx-post" in child.attrs:
context_menu = {
"hx-post": "/" + "/".join(child.attrs["hx-post"].split("/")[2:]),
"data_tooltip": child.attrs["data-tooltip"],
}
if "hx-vals" in child.attrs:
args = json.loads(child.attrs["hx-vals"])
args_to_use = {key: value for key, value in args.items() if key != "g_id"}
context_menu.update(args_to_use)
res.append(context_menu)
if not found:
assert False, "No element with id 'cmcm_' found in the return elements"
return res
def debug_print(ft, attr1st=False):
return html2ft(to_xml(ft), attr1st=attr1st)
def extract_table_values(element, header=True):
"""
Given element with tags and attributes
Try to find the table values
:param element:
:param header: search for header and add it to the result
:return:
"""
# first, get the header
if header:
header = search_elements_by_name(element, attrs={"class": "dt-row dt-header"})[0]
header_map = {}
res = OrderedDict()
for row in header.children:
col_index = row.attrs["data-col"]
name_element = search_elements_by_name(row, attrs={"name": "dt-header-title"})[0]
name = name_element.children[0] if len(name_element.children) > 0 else name_element.text
header_map[col_index] = name
res[name] = []
body = search_elements_by_name(element, attrs={"class": "dt-body"})[0]
for row in body.children:
for col in row.children:
col_index = col.attrs["data-col"]
cell_element = search_elements_by_name(col, attrs={"name": "dt-cell-content"})[0]
cell_value = cell_element.children[0] if len(cell_element.children) > 0 else cell_element.text
res[header_map[col_index]].append(cell_value)
return res
else:
body = search_elements_by_name(element, attrs={"class": "dt-body"})[0]
res = []
for row in body.children:
row_values = []
for col in row.children:
column = search_elements_by_name(col, attrs={"name": "dt-cell-content"})
if len(column) > 0:
cell_element = search_elements_by_name(col, attrs={"name": "dt-cell-content"})[0]
cell_value = cell_element.children[0] if len(cell_element.children) > 0 else cell_element.text
row_values.append(cell_value)
res.append(row_values)
return res
def extract_table_values_new(ft, header=True):
def _get_cell_content_value(cell_element):
# try using data-tooltip
tooltip_element = search_first_with_attribute(cell_element, None, "data-tooltip")
if tooltip_element is not None:
return tooltip_element.attrs["data-tooltip"]
# for checkboxes, use the name of the NotStr element
svg_element = search_elements_by_name(cell_element, "NotStr")
if svg_element:
match = compiled_svg_pattern.search(svg_element[0].s)
if match:
svg_name = match.group(1)
return True if svg_name == "checked" else False if svg_name == "unchecked" else None
return None
# first, get the header
if header:
header = search_elements_by_name(ft, attrs={"class": "dt2-header"}, comparison_method='contains')[0]
header_map = {}
res = OrderedDict()
for row in header.children:
col_id = row.attrs["data-col"]
title = row.attrs["data-tooltip"]
header_map[col_id] = title
res[title] = []
body = search_elements_by_name(ft, attrs={"class": "dt2-body"}, comparison_method='contains')[0]
for row in body.children:
for col in row.children:
col_id = col.attrs["data-col"]
cell_value = _get_cell_content_value(col)
res[header_map[col_id]].append(cell_value)
return res
else:
body = search_elements_by_name(ft, attrs={"class": "dt2-body"})[0]
res = []
for row in body.children:
row_values = []
for col in row.children:
columns = search_elements_by_name(col, attrs={"class": "dt2-cell-content"}, comparison_method="contains")
cell_value = _get_cell_content_value(columns)
row_values.append(cell_value)
res.append(row_values)
return res
def extract_footer_values(element):
body = search_elements_by_name(element, attrs={"class": "dt-table-footer"})[0]
res = []
for row in body.children:
row_values = []
for col in row.children:
cell_element = search_elements_by_name(col, attrs={"name": "dt-cell-content"})[0]
cell_value = cell_element.children[0] if len(cell_element.children) > 0 else cell_element.text
row_values.append(cell_value)
res.append(row_values)
return res
def extract_popup_content(element, filter_input=True) -> OrderedDict:
"""
Extract the checkboxes and their values from the popup content
:param element:
:param filter_input: add the value of the filter input if requested.
:return:
"""
res = OrderedDict()
if filter_input:
filter_value_element = search_elements_by_name(element, attrs={"name": "dt-popup-filter-input"})[0]
res["__filter_input__"] = _get_element_value(filter_value_element) or ''
checkboxes_div = search_elements_by_name(element, attrs={"class": 'dt-filter-popup-content'})[0]
checkboxes_elements = search_elements_by_name(checkboxes_div, attrs={"type": "checkbox"})
for element in checkboxes_elements:
res[element.attrs['value']] = 'checked' in element.attrs
return res
def to_array(dataframe: pd.DataFrame) -> list:
return [[val for val in row] for _, row in dataframe.iterrows()]
def _get_element_value(element):
return element.children[0] if len(element.children) > 0 else element.text
def icon(name: str):
return NotStr(f'<svg name="{name}"')
def div_icon(name: str):
return Div(NotStr(f'<svg name="{name}"'))

View File

@@ -0,0 +1,144 @@
import pandas as pd
import pytest
from fasthtml.components import *
from fasthtml.xtend import Script
from components.datagrid_new.components.ColumnsSettings import ColumnsSettings
from components.datagrid_new.components.DataGrid import DataGrid
from components.datagrid_new.constants import ColumnType
from core.settings_management import SettingsManager, MemoryDbEngine
from helpers import matches, search_elements_by_path
COLUMNS_SETTINGS_ID = "columns_settings_id"
TEST_GRID_ID = "test_grid_id"
TEST_GRID_KEY = "test_grid_key"
@pytest.fixture()
def empty_dg(session):
return DataGrid(session,
_id=TEST_GRID_ID,
settings_manager=SettingsManager(MemoryDbEngine()),
key=TEST_GRID_KEY)
@pytest.fixture()
def dg(empty_dg):
df = pd.DataFrame({
'Name': ['Alice', 'Bob'],
'Age': [20, 25],
'Male': [False, True],
})
# hack for the test because multiple dg are created with the same id
empty_dg._columns_settings._owner = empty_dg
empty_dg.init_from_dataframe(df)
return empty_dg
@pytest.fixture
def cs(session, dg):
return ColumnsSettings(session, COLUMNS_SETTINGS_ID, owner=dg)
def test_i_can_render(cs):
actual = cs.__ft__()
expected = Div(
Div(
H1("Columns Settings"),
Div(
Div(), # header
Div(), # body
Div(), # buttons
cls="dt2-cs-container"
),
),
Script(f"bindColumnsSettings('{cs.get_id()}')"),
id=cs.get_id(),
)
assert matches(actual, expected)
def test_i_can_render_when_no_dataframe(empty_dg):
columns_settings = empty_dg._columns_settings
actual = columns_settings.__ft__()
expected = Div(
Div(
H1("Columns Settings"),
Div(
Div(), # header
Div(), # body
Div(), # buttons
cls="dt2-cs-container"
),
),
Script(f"bindColumnsSettings('{columns_settings.get_id()}')"),
id=columns_settings.get_id(),
)
assert matches(actual, expected)
def test_i_can_render_columns_settings(cs):
def _mk_options(selected_value):
return [Option(value.value, selected=True if selected_value == value else None) for value in ColumnType]
actual = cs.__ft__()
to_compare = search_elements_by_path(actual, "div", attrs={"class": "dt2-cs-container"})[0]
expected = Div(
Div(
Div(cls="place-self-center"),
Div("Title"),
Div("Type"),
Div("Visible"),
Div("Usable"),
Div("Width"),
cls="dt2-cs-header dt2-cs-columns"
),
Div(
Div(
A(cls="dt2-item-handle"),
Input(name="title_name", type="input", value="Name"),
Select(
*_mk_options(ColumnType.Text),
name="type_name",
),
Input(name=f"visible_name", type="checkbox", checked=True),
Input(name=f"usable_name", type="checkbox", checked=True),
Input(name=f"width_name", type="input", value=100),
data_col="name",
),
Div(
A(cls="dt2-item-handle"),
Input(name="title_age", type="input", value="Age"),
Select(
*_mk_options(ColumnType.Number),
name="type_age",
),
Input(name=f"visible_age", type="checkbox", checked=True),
Input(name=f"usable_age", type="checkbox", checked=True),
Input(name=f"width_age", type="input", value=100),
data_col="age",
),
Div(
A(cls="dt2-item-handle"),
Input(name="title_male", type="input", value="Male"),
Select(
*_mk_options(ColumnType.Bool),
name="type_male",
),
Input(name=f"visible_male", type="checkbox", checked=True),
Input(name=f"usable_male", type="checkbox", checked=True),
Input(name=f"width_male", type="input", value=100),
data_col="male",
),
),
Div(), # buttons
cls="dt2-cs-container",
)
assert matches(to_compare, expected)

2105
tests/test_datagrid.py Normal file

File diff suppressed because it is too large Load Diff

454
tests/test_datagrid_new.py Normal file
View File

@@ -0,0 +1,454 @@
from collections import OrderedDict
import pandas as pd
import pytest
from fasthtml.components import *
from components.datagrid_new.components.DataGrid import DataGrid
from components.datagrid_new.constants import ColumnType, ViewType
from components.datagrid_new.settings import DataGridColumnState, DatagridView
from core.settings_management import SettingsManager, MemoryDbEngine
from helpers import matches, search_elements_by_path, extract_table_values_new, search_elements_by_name, div_icon, \
Contains
TEST_GRID_ID = "testing_grid_id"
TEST_GRID_KEY = "testing_grid_key"
@pytest.fixture()
def empty_dg(session):
return DataGrid(session,
_id=TEST_GRID_ID,
settings_manager=SettingsManager(MemoryDbEngine()),
key=TEST_GRID_KEY)
@pytest.fixture()
def dg(empty_dg):
df = pd.DataFrame({
'Name': ['Alice', 'Bob'],
'Age': [20, 25],
'Is Student': [True, False],
})
empty_dg.init_from_dataframe(df)
return empty_dg
@pytest.fixture()
def dg_with_views(dg):
view1_columns = [
DataGridColumnState('name', 0, 'Name', type=ColumnType.Text, visible=True, usable=True, width=100),
DataGridColumnState('age', 1, 'Age', type=ColumnType.Number, visible=False, usable=True, width=120),
]
view2_columns = [
DataGridColumnState('name', 0, 'Name', type=ColumnType.Text, visible=False, usable=True, width=150),
DataGridColumnState('age', 1, 'Age', type=ColumnType.Number, visible=True, usable=True, width=180),
]
dg.add_view("View 1", view1_columns)
dg.add_view("View 2", view2_columns)
return dg
def test_i_can_render_datagrid(empty_dg):
actual = empty_dg.__ft__()
expected = Div(
Div(id=f"tt_{TEST_GRID_ID}"),
Div(), # menu
Div(
Div(id=f"t_{TEST_GRID_ID}", ), # table
Div(id=f"sb_{TEST_GRID_ID}"), # sidebar
cls="dt2-main",
),
Script(),
id=TEST_GRID_ID
)
assert matches(actual, expected)
def test_i_can_render_dataframe(dg):
actual = dg.__ft__()
to_compare = search_elements_by_path(actual, "div", attrs={"id": f"t_{TEST_GRID_ID}"})[0]
expected = Div(
Div(id=f"tsm_{TEST_GRID_ID}"), # selection manager
Div(id=f"tdd_{TEST_GRID_ID}"), # cell drop down
Div(id=f"tcdd_{TEST_GRID_ID}"), # cell drop down
Div(), # Keyboard navigation
Div(
Div(id=f"scb_{TEST_GRID_ID}"), # container for the scroll bars
Div(id=f"th_{TEST_GRID_ID}"), # header
Div(id=f"tb_{TEST_GRID_ID}"), # table
Div(id=f"tf_{TEST_GRID_ID}"), # footer
cls="dt2-inner-table"
)
)
assert matches(to_compare, expected)
def test_i_correctly_render_dataframe_content(dg):
actual = dg.__ft__()
table_content = extract_table_values_new(actual, header=True)
assert table_content == OrderedDict({'Name': ['Alice', 'Bob'],
'Age': ['20', '25'],
'Is Student': [True, False]})
def test_i_can_render_text_cell(dg):
actual = dg.__ft__()
to_compare = search_elements_by_name(actual, "div", attrs={"data-col": f"name"})[1] # the first is the header
expected = Div(
Div('Alice', data_tooltip='Alice', cls='truncate dt2-cell-content-text'),
data_col='name',
style='width:100px;',
cls='dt2-cell'
)
assert matches(to_compare, expected)
def test_i_can_render_number_cell(dg):
actual = dg.__ft__()
to_compare = search_elements_by_name(actual, "div", attrs={"data-col": f"age"})[1] # the first is the header
expected = Div(
Div('20', data_tooltip='20', cls='truncate dt2-cell-content-number'),
data_col='age',
style='width:100px;',
cls='dt2-cell'
)
assert matches(to_compare, expected)
def test_i_can_render_boolean_cells(dg):
actual = dg.__ft__()
to_compare = search_elements_by_name(actual, "div", attrs={"data-col": f"is_student"})[1] # the first is the header
expected = Div(
Div(div_icon("checked"), cls='dt2-cell-content-checkbox'),
data_col='is_student',
style='width:100px;',
cls='dt2-cell'
)
assert matches(to_compare, expected)
to_compare = search_elements_by_name(actual, "div", attrs={"data-col": f"is_student"})[2] # the first is the header
expected = Div(
Div(div_icon("unchecked"), cls='dt2-cell-content-checkbox'),
data_col='is_student',
style='width:100px;',
cls='dt2-cell'
)
assert matches(to_compare, expected)
def test_i_can_render_when_not_visible(dg):
updates = [{"col_id": "name", "visible": False}]
dg.update_columns_state(updates)
actual = dg.__ft__()
to_compare = search_elements_by_name(actual, "div", attrs={"class": "dt2-inner-table"})[0]
expected = Div(
Div(id=f"scb_{TEST_GRID_ID}"),
Div(
Div(data_col='name',
data_tooltip="Show column 'Name'",
cls=Contains("dt2-header-hidden")),
Div(data_col='age',
data_tooltip="Age",
cls=Contains("dt2-cell"),
style="width:100px;"),
Div(data_col='is_student',
data_tooltip="Is Student",
cls=Contains("dt2-cell"),
style="width:100px;"),
id=f"th_{TEST_GRID_ID}"),
Div(
Div(
Div(cls="dt2-col-hidden"),
Div(data_col='age',
cls="dt2-cell",
style="width:100px;"),
Div(data_col='is_student',
cls="dt2-cell",
style="width:100px;")),
Div(
Div(cls="dt2-col-hidden"),
Div(data_col='age',
cls="dt2-cell",
style="width:100px;"),
Div(data_col='is_student',
cls="dt2-cell",
style="width:100px;")),
id=f"tb_{TEST_GRID_ID}"),
Div(
Div(
Div(cls="dt2-col-hidden"),
Div(data_col='age',
cls=Contains("dt2-cell"),
style="width:100px;"),
Div(data_col='is_student',
cls=Contains("dt2-cell"),
style="width:100px;")
),
id=f"tf_{TEST_GRID_ID}"),
cls="dt2-inner-table"
)
assert matches(to_compare, expected)
def test_i_can_render_when_not_usable(dg):
updates = [{"col_id": "name", "usable": False}]
dg.update_columns_state(updates)
actual = dg.__ft__()
to_compare = search_elements_by_name(actual, "div", attrs={"class": "dt2-inner-table"})[0]
expected = Div(
Div(id=f"scb_{TEST_GRID_ID}"),
Div(
None,
Div(data_col='age',
data_tooltip="Age",
cls=Contains("dt2-cell"),
style="width:100px;"),
Div(data_col='is_student',
data_tooltip="Is Student",
cls=Contains("dt2-cell"),
style="width:100px;"),
id=f"th_{TEST_GRID_ID}"),
Div(
Div(
None,
Div(data_col='age',
cls="dt2-cell",
style="width:100px;"),
Div(data_col='is_student',
cls="dt2-cell",
style="width:100px;")),
Div(
None,
Div(data_col='age',
cls="dt2-cell",
style="width:100px;"),
Div(data_col='is_student',
cls="dt2-cell",
style="width:100px;")),
id=f"tb_{TEST_GRID_ID}"),
Div(
Div(
None,
Div(data_col='age',
cls=Contains("dt2-cell"),
style="width:100px;"),
Div(data_col='is_student',
cls=Contains("dt2-cell"),
style="width:100px;")
),
id=f"tf_{TEST_GRID_ID}"),
cls="dt2-inner-table"
)
assert matches(to_compare, expected)
def tests_i_can_load_dataframe_from_excel(dg, excel_file_content_with_sheet_name):
dg._file_upload.file_name = "fake_excel_file.xlsx"
dg._file_upload.file_content = excel_file_content_with_sheet_name
dg._file_upload.selected_sheet_name = "sheet_name"
dg.init_from_excel()
# validate the state
assert dg.get_state().columns == [
DataGridColumnState('column_1', 0, "Column 1 ", ColumnType.Text, True, True, 100),
DataGridColumnState('column_2', 1, "Column 2", ColumnType.Bool, True, True, 100),
DataGridColumnState('column_3', 2, "Column 3 ", ColumnType.Number, True, True, 100)]
# validate the settings
assert dg.get_settings().file_name == "fake_excel_file.xlsx"
assert dg.get_settings().selected_sheet_name == "sheet_name"
# validate the content
assert extract_table_values_new(dg.__ft__(), header=True) == OrderedDict([('Column 1 ', ['Aba', 'Johan', 'Kodjo']),
('Column 2', [False, True, True]),
('Column 3 ', ['10', '20', '30'])])
# validate that the dataframe columns match the settings
assert dg._df.columns.tolist() == [col_def.col_id for col_def in dg.get_state().columns]
def test_update_columns_state_no_updates(dg):
# Test when updates is None
previous_cols = dg._state.columns.copy()
dg.update_columns_state(None)
assert dg._state.columns == previous_cols
def test_update_columns_state_delta_mode_existing_col(dg):
updates = [{"col_id": "name", "title": "Updated Column", "visible": False, "usable": False, "width": 150}]
before = dg.get_state().columns.copy()
dg.update_columns_state(updates, mode="delta")
# Assert the existing column was updated
updated_col = dg._state.columns[0]
assert updated_col.title == "Updated Column"
assert updated_col.visible is False
assert updated_col.usable is False
assert updated_col.width == 150
# Check that the other columns are still the same
assert before[1:] == dg._state.columns[1:]
def test_update_columns_state_delta_mode_nonexistent_col(dg):
updates = [{"col_id": "col3", "title": "New Column", "visible": True, "width": 300, "type": "Text"}]
before = dg.get_state().columns.copy()
table, sidebar, select_view = dg.update_columns_state(updates, mode="delta")
# Assert a new column was created and added to the state
new_col = dg._state.columns[-1]
assert new_col.col_id == "col3"
assert new_col.title == "New Column"
assert new_col.visible is True
assert new_col.width == 300
assert new_col.type == ColumnType.Text
# Check that the other columns are untouched
assert before[:3] == dg._state.columns[:3]
# check that the new column is displayed
table_values = extract_table_values_new(table, header=True)
assert table_values == OrderedDict({'Name': ['Alice', 'Bob'],
'Age': ['20', '25'],
'Is Student': [True, False],
'New Column': ['None', 'None']})
def test_update_columns_state_replace_mode(dg):
# Test with replacing the entire columns' state
updates = [
{"col_id": "colX", "title": "Column X", "visible": True, "width": 250, "type": "Number"},
{"col_id": "colY", "title": "Column Y", "visible": True, "width": 180, "type": "Text"}
]
table, sidebar, select_view = dg.update_columns_state(updates, mode="replace")
# Assert old columns were replaced
assert len(dg._state.columns) == 2
new_col_1 = dg._state.columns[0]
new_col_2 = dg._state.columns[1]
assert new_col_1.col_id == "colX"
assert new_col_1.title == "Column X"
assert new_col_1.width == 250
assert new_col_1.type == ColumnType.Number
assert new_col_2.col_id == "colY"
assert new_col_2.title == "Column Y"
assert new_col_2.width == 180
assert new_col_2.type == ColumnType.Text
# check that the new column is displayed
table_values = extract_table_values_new(table, header=True)
assert table_values == OrderedDict([('Column X', ['None', 'None']),
("Column Y", ['None', 'None'])])
def test_new_columns_settings_saved(dg):
columns_definitions = ([
{"col_id": "name", "title": "New Name", "type": "Number", "visible": True, "width": 100},
{"col_id": "age", "title": "New Age", "type": "Boolean", "visible": False, "width": 150},
{"col_id": "is_student", "title": "New Is Student", "type": "Text", "visible": True, "width": 200},
])
dg.update_columns_state(columns_definitions)
# state is updated
assert dg.get_state().columns == [
DataGridColumnState('name', 0, 'New Name', type=ColumnType.Number, visible=True, usable=True, width=100),
DataGridColumnState('age', 1, 'New Age', type=ColumnType.Bool, visible=False, usable=True, width=150),
DataGridColumnState('is_student', 2, 'New Is Student', type=ColumnType.Text, visible=True, usable=True, width=200)
]
# stated is saved in db
from_db = dg._db.load_state()
assert from_db.columns == [
DataGridColumnState('name', 0, 'New Name', type=ColumnType.Number, visible=True, usable=True, width=100),
DataGridColumnState('age', 1, 'New Age', type=ColumnType.Bool, visible=False, usable=True, width=150),
DataGridColumnState('is_student', 2, 'New Is Student', type=ColumnType.Text, visible=True, usable=True, width=200)
]
def test_update_columns_state_with_reordering(dg):
columns_definitions = ([
{"col_id": "age", "title": "Age", "type": "Number", "visible": True, "width": 100},
{"col_id": "name", "title": "Name", "type": "Text", "visible": True, "width": 100},
{"col_id": "is_student", "title": "Is Student", "type": "Boolean", "visible": True, "width": 100},
])
dg.update_columns_state(columns_definitions, mode="replace") # reorder is only supported in 'replace' mode
assert [col.col_id for col in dg.get_state().columns] == ["age", "name", "is_student"]
actual = extract_table_values_new(dg.__ft__(), header=True)
assert list(actual.keys()) == ["Age", "Name", "Is Student"]
def test_add_view_successful(dg):
view_name = "Test View"
columns = [
DataGridColumnState('name', 0, 'Name', type=ColumnType.Text, visible=False, usable=True, width=100),
DataGridColumnState('age', 1, 'Age', type=ColumnType.Number, visible=True, usable=False, width=120),
DataGridColumnState('is_student', 2, 'Is Student', type=ColumnType.Bool, visible=False, usable=True, width=130),
]
dg.add_view(view_name, columns)
assert dg.get_settings().views == [DatagridView(view_name, ViewType.Table, columns)]
assert dg.get_state().selected_view == view_name
# Check that it's saved in db
settings_from_db = dg._db.load_settings()
assert settings_from_db.views == [DatagridView(view_name, ViewType.Table, columns)]
state_from_db = dg._db.load_state()
assert state_from_db.selected_view == view_name
def test_add_view_duplicate_name(dg):
view_name = "Duplicate View"
columns = [
DataGridColumnState('name', 0, 'Name', type=ColumnType.Text, visible=False, usable=True, width=100),
DataGridColumnState('age', 1, 'Age', type=ColumnType.Number, visible=True, usable=False, width=120),
DataGridColumnState('is_student', 2, 'Is Student', type=ColumnType.Bool, visible=False, usable=True, width=130),
]
dg.add_view(view_name, columns)
with pytest.raises(ValueError) as e:
dg.add_view(view_name, [])
assert str(e.value) == f"View '{view_name}' already exists"
def test_add_view_is_done_with_deep_copy(dg):
view_name = "Test View"
columns = [DataGridColumnState('name', 0, 'Name', type=ColumnType.Text, visible=False, usable=True, width=100)]
dg.add_view(view_name, columns)
columns[0].title = "New Name"
assert dg.get_settings().views[0].columns[0].title == "Name"
def test_change_view_to_existing_view(dg_with_views):
dg_with_views.change_view("View 2")
assert dg_with_views.get_state().selected_view == "View 2"
assert dg_with_views.get_state().columns[0].visible is False
assert dg_with_views.get_state().columns[0].width == 150
assert dg_with_views.get_state().columns[1].visible is True
assert dg_with_views.get_state().columns[1].width == 180
def test_change_view_to_nonexistent_view(dg_with_views):
with pytest.raises(ValueError) as e:
dg_with_views.change_view("Non Existent View")
assert str(e.value) == "View 'Non Existent View' does not exist"

View File

@@ -0,0 +1,24 @@
from components.datagrid.DataGridCommandManager import DataGridCommandManager
def test_i_can_merge_when_none():
assert DataGridCommandManager.merge(None, None) is None
assert DataGridCommandManager.merge({}, None) == {}
assert DataGridCommandManager.merge(None, {}) == {}
def test_i_can_merge():
first = {"a": "a", "b": "b"}
second = {"c": "c", "d": "d"}
third = {"e": "e", "f": "f"}
res = DataGridCommandManager.merge(first, second, third)
assert res == {"a": "a", "b": "b", "c": "c", "d": "d", "e": "e", "f": "f"}
def test_i_can_merge_when_conflicts():
first = {"a": "first-value", "b": "b"}
second = {"a": "second-value", "c": "c"}
res = DataGridCommandManager.merge(first, second)
assert res == {"a": "first-value second-value", "b": "b", "c": "c"}

181
tests/test_dbengine.py Normal file
View File

@@ -0,0 +1,181 @@
import os.path
import shutil
import pandas as pd
import pytest
from core.dbengine import DbEngine, TAG_PARENT
from core.settings_objects import BudgetTrackerSettings, BudgetTrackerFile, BudgetTrackerFiles
DB_ENGINE_ROOT = "TestDBEngineRoot"
FAKE_USER_ID = "FakeUserId"
@pytest.fixture()
def engine():
if os.path.exists(DB_ENGINE_ROOT):
shutil.rmtree(DB_ENGINE_ROOT)
engine = DbEngine(DB_ENGINE_ROOT)
engine.init()
return engine
@pytest.fixture()
def dummy_obj():
return BudgetTrackerSettings(
spread_sheet="spread_sheet",
col_row_num="row_number",
col_project="project",
col_owner="owner",
col_capex="capex",
col_details="details",
col_supplier="supplier",
col_budget_amt="budget",
col_actual_amt="actual",
col_forecast5_7_amt="forecast5_7",
)
@pytest.fixture()
def dummy_obj2():
return BudgetTrackerSettings(
spread_sheet="spread_sheet2",
col_row_num="row_number2",
col_project="project2",
col_owner="owner2",
col_capex="capex2",
col_details="details2",
col_supplier="supplier2",
col_budget_amt="budget2",
col_actual_amt="actual2",
col_forecast5_7_amt="forecast5_72",
)
def test_i_can_test_init():
if os.path.exists(DB_ENGINE_ROOT):
shutil.rmtree(DB_ENGINE_ROOT)
engine = DbEngine(DB_ENGINE_ROOT)
assert not engine.is_initialized()
engine.init()
assert engine.is_initialized()
def test_i_can_save_and_load(engine, dummy_obj):
engine.save(FAKE_USER_ID, "MyEntry", dummy_obj)
res = engine.load(FAKE_USER_ID, "MyEntry")
assert isinstance(res, BudgetTrackerSettings)
assert res.spread_sheet == dummy_obj.spread_sheet
assert res.col_row_num == dummy_obj.col_row_num
assert res.col_project == dummy_obj.col_project
assert res.col_owner == dummy_obj.col_owner
assert res.col_capex == dummy_obj.col_capex
assert res.col_details == dummy_obj.col_details
assert res.col_supplier == dummy_obj.col_supplier
assert res.col_budget_amt == dummy_obj.col_budget_amt
assert res.col_actual_amt == dummy_obj.col_actual_amt
assert res.col_forecast5_7_amt == dummy_obj.col_forecast5_7_amt
def test_i_can_save_using_ref(engine):
data = {
'Key1': ['A', 'B', 'C'],
'Key2': ['X', 'Y', 'Z'],
'Percentage': [0.1, 0.2, 0.15],
}
df = pd.DataFrame(data)
obj = BudgetTrackerFile(2024, 8, data=df)
engine.save(FAKE_USER_ID, "MyEntry", obj)
res = engine.load(FAKE_USER_ID, "MyEntry")
assert isinstance(res, BudgetTrackerFile)
assert res.year == obj.year
assert res.month == obj.month
assert res.data.to_dict() == obj.data.to_dict()
def test_i_can_use_ref_when_subclass(engine):
data1 = {'Key': ['A'], 'Value': [0.1]}
data2 = {'Key': ['B'], 'Value': [0.2]}
file1 = BudgetTrackerFile(2024, 8, data=pd.DataFrame(data1))
file2 = BudgetTrackerFile(2024, 9, data=pd.DataFrame(data2))
files = BudgetTrackerFiles([file1, file2])
engine.save(FAKE_USER_ID, "MyEntry", files)
res = engine.load(FAKE_USER_ID, "MyEntry")
assert isinstance(res, BudgetTrackerFiles)
assert len(res.files) == 2
def test_i_can_put_and_get_one_object(engine, dummy_obj):
engine.put(FAKE_USER_ID, "MyEntry", "key1", dummy_obj)
from_db = engine.get(FAKE_USER_ID, "MyEntry", "key1")
assert from_db == dummy_obj
def test_i_can_put_and_get_multiple_objects(engine, dummy_obj, dummy_obj2):
engine.put(FAKE_USER_ID, "MyEntry", "key1", dummy_obj)
engine.put(FAKE_USER_ID, "MyEntry", "key2", dummy_obj2)
from_db1 = engine.get(FAKE_USER_ID, "MyEntry", "key1")
from_db2 = engine.get(FAKE_USER_ID, "MyEntry", "key2")
assert from_db1 == dummy_obj
assert from_db2 == dummy_obj2
all_items = engine.get(FAKE_USER_ID, "MyEntry")
assert all_items == [dummy_obj, dummy_obj2]
def test_i_automatically_replace_keys(engine, dummy_obj, dummy_obj2):
engine.put(FAKE_USER_ID, "MyEntry", "key1", dummy_obj)
engine.put(FAKE_USER_ID, "MyEntry", "key1", dummy_obj2)
from_db1 = engine.get(FAKE_USER_ID, "MyEntry", "key1")
assert from_db1 == dummy_obj2
all_items = engine.get(FAKE_USER_ID, "MyEntry")
assert all_items == [dummy_obj2]
def test_i_do_not_save_twice_when_the_entries_are_the_same(engine, dummy_obj):
engine.put(FAKE_USER_ID, "MyEntry", "key1", dummy_obj)
entry_content = engine.load(FAKE_USER_ID, "MyEntry")
assert entry_content[TAG_PARENT] == [None]
# Save the same entry again
engine.put(FAKE_USER_ID, "MyEntry", "key1", dummy_obj)
entry_content = engine.load(FAKE_USER_ID, "MyEntry")
assert entry_content[TAG_PARENT] == [None] # still no other parent
def test_i_can_put_many(engine, dummy_obj, dummy_obj2):
engine.put_many(FAKE_USER_ID, "MyEntry", [dummy_obj, dummy_obj2])
from_db1 = engine.get(FAKE_USER_ID, "MyEntry", "spread_sheet")
from_db2 = engine.get(FAKE_USER_ID, "MyEntry", "spread_sheet2")
assert from_db1 == dummy_obj
assert from_db2 == dummy_obj2
entry_content = engine.load(FAKE_USER_ID, "MyEntry")
assert entry_content[TAG_PARENT] == [None] # only one save was made
def test_i_can_do_not_save_in_not_necessary(engine, dummy_obj, dummy_obj2):
engine.put_many(FAKE_USER_ID, "MyEntry", [dummy_obj, dummy_obj2])
engine.put_many(FAKE_USER_ID, "MyEntry", [dummy_obj, dummy_obj2])
entry_content = engine.load(FAKE_USER_ID, "MyEntry")
assert entry_content[TAG_PARENT] == [None] # Still None, nothing was saved

108
tests/test_dummydbengine.py Normal file
View File

@@ -0,0 +1,108 @@
import os.path
import pytest
from core.dbengine import DbException
from core.settings_management import DummyDbEngine
from core.settings_objects import BudgetTrackerSettings
settings_file = DummyDbEngine().db_path
FAKE_USER_ID = "FakeUserId"
@pytest.fixture(autouse=True)
def setup_and_finalize():
if os.path.exists(settings_file):
os.remove(settings_file)
yield
if os.path.exists(settings_file):
os.remove(settings_file)
@pytest.fixture()
def engine():
return DummyDbEngine()
def test_i_can_save_and_load(engine):
obj = BudgetTrackerSettings(
spread_sheet="spread_sheet",
col_row_num="row_number",
col_project="project",
col_owner="owner",
col_capex="capex",
col_details="details",
col_supplier="supplier",
col_budget_amt="budget",
col_actual_amt="actual",
col_forecast5_7_amt="forecast5_7",
)
engine.save(FAKE_USER_ID, "MyEntry", obj)
res = engine.load(FAKE_USER_ID, "MyEntry")
assert isinstance(res, BudgetTrackerSettings)
assert res.spread_sheet == obj.spread_sheet
assert res.col_row_num == obj.col_row_num
assert res.col_project == obj.col_project
assert res.col_owner == obj.col_owner
assert res.col_capex == obj.col_capex
assert res.col_details == obj.col_details
assert res.col_supplier == obj.col_supplier
assert res.col_budget_amt == obj.col_budget_amt
assert res.col_actual_amt == obj.col_actual_amt
assert res.col_forecast5_7_amt == obj.col_forecast5_7_amt
def test_i_can_save_and_modify(engine):
obj = BudgetTrackerSettings()
engine.save(FAKE_USER_ID, "MyEntry", obj)
obj = BudgetTrackerSettings(
spread_sheet="modified_spread_sheet",
col_row_num="modified_row_number",
col_project="modified_project",
col_owner="modified_owner",
col_capex="modified_capex",
col_details="modified_details",
col_supplier="modified_supplier",
col_budget_amt="modified_budget",
col_actual_amt="modified_actual",
col_forecast5_7_amt="forecast5_7",
)
engine.save(FAKE_USER_ID, "MyEntry", obj)
res = engine.load(FAKE_USER_ID, "MyEntry")
assert isinstance(res, BudgetTrackerSettings)
assert res.spread_sheet == obj.spread_sheet
assert res.col_row_num == obj.col_row_num
assert res.col_project == obj.col_project
assert res.col_owner == obj.col_owner
assert res.col_capex == obj.col_capex
assert res.col_details == obj.col_details
assert res.col_supplier == obj.col_supplier
assert res.col_budget_amt == obj.col_budget_amt
assert res.col_actual_amt == obj.col_actual_amt
assert res.col_forecast5_7_amt == obj.col_forecast5_7_amt
def test_i_cannot_load_if_no_setting_file(engine):
with pytest.raises(DbException) as ex:
engine.load(FAKE_USER_ID, "MyEntry")
assert str(ex.value) == f"Entry 'MyEntry' is not found."
def test_i_cannot_load_if_no_entry_found(engine):
obj = BudgetTrackerSettings()
engine.save(FAKE_USER_ID, "AnotherEntry", obj)
with pytest.raises(DbException) as ex:
engine.load(FAKE_USER_ID, "MyEntry")
assert str(ex.value) == f"Entry 'MyEntry' is not found."

331
tests/test_helpers.py Normal file
View File

@@ -0,0 +1,331 @@
from collections import OrderedDict
import pandas as pd
import pytest
from fastcore.basics import NotStr
from fastcore.xml import to_xml
from fasthtml.components import *
from components.datagrid.DataGrid import DataGrid
from helpers import matches, search_elements_by_name, search_elements_by_path, extract_table_values, get_from_html, \
extract_popup_content, \
Empty, get_path_attributes, find_first_match, StartsWith, search_first_with_attribute, Contains
@pytest.fixture
def sample_structure():
"""
A pytest fixture to provide a sample tree structure for testing.
"""
return Html(
Header(cls="first-class"),
Body(
"hello world",
Div(
Span(cls="highlight"),
Span(id="inner", name="child"),
id="content"),
),
Footer(),
)
@pytest.mark.parametrize("value, expected, expected_error", [
(Div(), "value",
"The types are different: <class 'fastcore.xml.FT'> != <class 'str'>, (div((),{}) != value)."),
(Div(), A(),
"The elements are different: 'div' != 'a'."),
(Div(Div()), Div(A()),
"Path 'div':\n\tThe elements are different: 'div' != 'a'."),
(Div(A(Span())), Div(A("element")),
"Path 'div.a':\n\tThe types are different: <class 'fastcore.xml.FT'> != <class 'str'>, (span((),{}) != element)."),
(Div(attr="one"), Div(attr="two"),
"Path 'div':\n\tThe values are different for 'attr' : 'one' != 'two'."),
(Div(A(attr="alpha")), Div(A(attr="beta")),
"Path 'div.a':\n\tThe values are different for 'attr' : 'alpha' != 'beta'."),
(Div(Div(), A()), Div(Div(), Span()),
"Path 'div':\n\tThe elements are different: 'a' != 'span'."),
(Div(A()), Div(A(attr="beta")),
"Path 'div.a':\n\tAttribute 'attr' is not found (with expected value: 'beta'). actual='{}'."),
(Div(id="one"), Div(id="two"),
"Path 'div#one':\n\tThe values are different for 'id' : 'one' != 'two'."),
(Div(id="same_id", attr="one"), Div(id="same_id", attr="two"),
"Path 'div#same_id':\n\tThe values are different for 'attr' : 'one' != 'two'."),
(Div(name="same_name", attr="one"), Div(name="same_name", attr="two"),
"Path 'div[name=same_name]':\n\tThe values are different for 'attr' : 'one' != 'two'."),
(Div(cls="same_class", attr="one"), Div(cls="same_class", attr="two"),
"Path 'div[class=same_class]':\n\tThe values are different for 'attr' : 'one' != 'two'."),
(Div(attr="value"), Div(Empty),
"Empty element expected, but found attributes {'attr': 'value'}."),
(Div(Div()), Div(Empty),
"Empty element expected, but found children (div((),{}),)."),
(Div(cls="a long attr"), Div(cls=StartsWith("different start")),
"Path 'div[class=a long attr]':\n\tAttribute 'class' does not start with 'different start': actual='a long attr', expected ='different start'."),
(Div(cls="a long attr"), Div(cls=Contains("not included")),
"Path 'div[class=a long attr]':\n\tAttribute 'class' does not contain 'not included': actual='a long attr', expected ='not included'."),
])
def test_matches_error_expected(value, expected, expected_error):
with pytest.raises(AssertionError) as error:
matches(value, expected)
assert error.value.args[0] == expected_error
@pytest.mark.parametrize("value, expected", [
(Div(), Div()),
(Div(A()), Div(A())),
(Div(id='do_not_validate'), Div(id='do_not_validate')),
(Div(A()), Div()), # children of actual are not selected
(Div(A(), Span(id="validate_please"), A(id="do_not_care")), Div(A(), Span(id="validate_please"))),
(Div(), Div(Empty)),
(Div(cls="a long attr"), Div(cls=StartsWith("a long"))),
(Div(cls="a long attr"), Div(cls=Contains("long"))),
])
def test_matches_success_expected(value, expected):
assert matches(value, expected)
def test_i_can_search_elements_by_name():
to_find = Table()
res = search_elements_by_name(to_find, "table")
assert res == [to_find]
ft = Div(Span(to_find))
res = search_elements_by_name(ft, "table")
assert res == [to_find]
ft = Div(Span(), Span(to_find))
res = search_elements_by_name(ft, "table")
assert res == [to_find]
ft = Div(to_find, Span(to_find))
res = search_elements_by_name(ft, "table")
assert res == [to_find, to_find]
def test_i_can_search_not_str_by_name():
to_find = NotStr("Hello World")
res = search_elements_by_name(to_find, "NotStr")
assert res == [to_find]
ft = Div(Span(to_find))
res = search_elements_by_name(ft, "NotStr")
assert res == [to_find]
def test_i_can_search_elements_by_name_with_attr_and_exact_compare():
to_find = Table(attr="value")
res = search_elements_by_name(to_find, None, {"attr": "value"})
assert res == [to_find]
ft = Div(Span(), Span(to_find))
res = search_elements_by_name(ft, None, {"attr": "value"})
assert res == [to_find]
ft = Div(Span(), Span(to_find))
res = search_elements_by_name(ft, "table", {"attr": "value"})
assert res == [to_find]
ft = Div(Span(), Span(to_find))
res = search_elements_by_name(ft, "other", {"attr": "value"})
assert res == []
ft = Div(Span(), Span(to_find))
res = search_elements_by_name(ft, "table", {"attr": "value2"})
assert res == []
ft = Div(Span(Table()))
res = search_elements_by_name(ft, "table", {"attr": "value"})
assert res == []
def test_i_can_search_elements_by_name_with_attr_and_contains_compare():
to_find = Table(attr="value1 value2 value3")
res = search_elements_by_name(to_find, None, {"attr": "value"}, comparison_method="contains")
assert res == [to_find]
ft = Div(Span(), Span(to_find))
res = search_elements_by_name(ft, None, {"attr": "value"}, comparison_method="contains")
assert res == [to_find]
ft = Div(Span(), Span(to_find))
res = search_elements_by_name(ft, "table", {"attr": "value"}, comparison_method="contains")
assert res == [to_find]
ft = Div(Span(), Span(to_find))
res = search_elements_by_name(ft, "other", {"attr": "value"}, comparison_method="contains")
assert res == []
ft = Div(Span(), Span(to_find))
res = search_elements_by_name(ft, "table", {"attr": "value4"}, comparison_method="contains")
assert res == []
ft = Div(Span(Table()))
res = search_elements_by_name(ft, "table", {"attr": "value"}, comparison_method="contains")
assert res == []
def test_i_can_select_path():
to_find = Table(attr="value")
res = search_elements_by_path(to_find, "table", None)
assert res == [to_find]
ft = Div(Span(to_find))
res = search_elements_by_path(ft, "div.span.table", None)
assert res == [to_find]
ft = Div(Span(to_find))
res = search_elements_by_path(ft, "span.table", None)
assert res == [to_find]
def test_i_can_select_path_with_attr():
to_find = Table(attr="value")
res = search_elements_by_path(to_find, "table", {"attr": "value"})
assert res == [to_find]
ft = Div(Span(to_find))
res = search_elements_by_path(ft, "div.span.table", {"attr": "value"})
assert res == [to_find]
ft = Div(Span(to_find))
res = search_elements_by_path(ft, "span.table", {"attr": "value"})
assert res == [to_find]
res = search_elements_by_path(to_find, "span.table", {"attr": "value2"})
assert res == []
def test_i_can_extract_table_values_from_ft():
df = pd.DataFrame({
'Name': ['Alice', 'Bob'],
'Age': [20, 25]
})
dg = DataGrid(df, id="testing_grid_id")
element = dg.__ft__()
assert extract_table_values(element) == OrderedDict({
'Name': ['Alice', 'Bob'],
'Age': ["20", "25"]
})
assert extract_table_values(element, header=False) == [
["Alice", "20"],
["Bob", "25"]
]
def test_i_can_extract_table_values_from_html():
df = pd.DataFrame({
'Name': ['Alice', 'Bob'],
'Age': [20, 25]
})
dg = DataGrid(df, id="testing_grid_id")
html = to_xml(dg.__ft__())
element = get_from_html(html)
assert extract_table_values(element) == OrderedDict({
'Name': ['Alice', 'Bob'],
'Age': ["20", "25"]
})
assert extract_table_values(element, header=False) == [
["Alice", "20"],
["Bob", "25"]
]
def test_i_can_extract_popup_content_from_ft():
df = pd.DataFrame({
'Name': ['Alice', 'Bob', 'Charlie'],
'Age': [20, 25, 30]
})
dg = DataGrid(df, id="testing_grid_id")
element = dg.mk_filter_popup_content("name")
assert extract_popup_content(element) == OrderedDict(
{'__filter_input__': '',
'Alice': False,
'Bob': False,
'Charlie': False,
}
)
def test_i_can_extract_popup_content_from_html():
df = pd.DataFrame({
'Name': ['Alice', 'Bob', 'Charlie'],
'Age': [20, 25, 30]
})
dg = DataGrid(df, id="testing_grid_id")
html = to_xml(dg.mk_filter_popup_content("name"))
element = get_from_html(html)
assert extract_popup_content(element) == OrderedDict(
{'__filter_input__': '',
'Alice': False,
'Bob': False,
'Charlie': False,
}
)
@pytest.mark.parametrize("path, expected_attributes", [
("div", {"tag": "div"}),
("div#my_id", {"tag": "div", "id": "my_id"}),
("div[class=my_class]", {"tag": "div", "class": "my_class"}),
("div#my_id[class=my_class]", {"tag": "div", "id": "my_id", "class": "my_class"}),
("div#my_id[a1=v1][a2=v2]", {"tag": "div", "id": "my_id", "a1": "v1", "a2": "v2"}),
("div#my_id[a1='v1']", {"tag": "div", "id": "my_id", "a1": "v1"}),
('div#my_id[a1="v1"]', {"tag": "div", "id": "my_id", "a1": "v1"}),
("div#my_id[a1='v-1']", {"tag": "div", "id": "my_id", "a1": "v-1"}),
("div#my_id[a1='v_1']", {"tag": "div", "id": "my_id", "a1": "v_1"}),
])
def test_i_can_get_path_attributes(path, expected_attributes):
assert get_path_attributes(path) == expected_attributes
def test_i_can_select_by_path():
# I can select the good one from a list
items = [Div(id='1'),
Div(id='2'),
Div(id='3')]
actual = find_first_match(items, "div#3")
assert actual == items[2]
# I can select using attribute
item = Div(Span(id="span_1"), Span(id="span_2"), id="div_1")
actual = find_first_match(item, "div.span#span_2")
assert actual == item.children[1]
# I can manage when no ft
items = [Div(Div("text")), Div(Div(Div(id="3"), id="2"), id="1")] # 'text' is not a ft, but there won't be any error
actual = find_first_match(items, "div.div.div")
assert actual.attrs["id"] == "3"
# None is returned when not found
assert find_first_match(item, "div.span#span_3") is None
@pytest.mark.parametrize("tag, attr, expected", [
("span", "class", ("span", "highlight")), # The tag and the attribute exist
("footer", "id", None), # The tag exists, but not the attribute
(None, "class", ("header", "first-class")), # First element with a given attribute
("p", "class", None), # The tag does not exist
("span", "id", ("span", "inner")), # Inner element
(None, "name", ("span", "child")), # Inner element
])
def test_i_can_search_first_with_attribute(tag, attr, expected, sample_structure):
result = search_first_with_attribute(sample_structure, tag, attr)
if expected is None:
assert result is None
else:
assert result.tag == expected[0]
assert attr in result.attrs
assert result.attrs[attr] == expected[1]

View File

@@ -0,0 +1,241 @@
import pytest
from components.BaseComponent import BaseComponent
from core.instance_manager import InstanceManager, SESSION_ID_KEY, NOT_LOGGED # Adjust import path as needed
class MockBaseComponent(BaseComponent):
"""
Mock BaseComponent that matches the expected behavior of a BaseComponent-compatible class.
"""
def __init__(self, session, instance_id, **kwargs):
super().__init__(session, instance_id, **kwargs)
self.kwargs = kwargs
def __repr__(self):
return f"MockBaseComponent(instance_id={self._id})"
class MockInstance:
"""
A mock class for non-BaseComponent objects.
"""
def __init__(self, _id, **kwargs):
self._id = _id
self.kwargs = kwargs
def __repr__(self):
return f"MockInstance(instance_id={self._id})"
class MockInstanceWithDispose(MockInstance):
def __init__(self, _id, **kwargs):
super().__init__(_id, **kwargs)
self.disposed = False
def dispose(self):
self.disposed = True
@pytest.fixture(autouse=True)
def reset_instance_manager():
"""
Automatically clears the `InstanceManager` before each test to ensure no state is carried between tests.
"""
InstanceManager.clear()
@pytest.fixture
def session():
"""
Fixture to provide a default mocked session dictionary with a fixed user_id.
"""
return {SESSION_ID_KEY: "test_user"}
@pytest.fixture
def instance_id():
"""
Fixture to provide a default instance_id for tests.
"""
return "instance_1"
@pytest.fixture
def base_component_instance(session, instance_id):
"""
Fixture to provide a mock instance of MockBaseComponent for tests.
"""
return MockBaseComponent(session, instance_id)
@pytest.fixture
def mock_instance(instance_id):
"""
Fixture to provide a mock instance of MockInstance for tests.
"""
return MockInstance(instance_id)
def test_get_creates_new_instance_if_not_exists(session, instance_id):
"""
Test that InstanceManager.get creates and returns a new instance when not already present.
"""
instance = InstanceManager.get(session, instance_id, instance_type=MockInstance)
assert isinstance(instance, MockInstance)
assert instance._id == instance_id
def test_get_creates_new_instance_if_not_exists_with_extra_value(session, instance_id):
"""
Test that InstanceManager.get creates and returns a new instance when not already present.
extra_param are passed to the constructor.
"""
instance = InstanceManager.get(session, instance_id, instance_type=MockInstance, extra_param="value")
assert isinstance(instance, MockInstance)
assert instance._id == instance_id
assert instance.kwargs.get("extra_param") == "value"
def test_get_creates_new_base_component_instance_if_not_exists(session, instance_id):
"""
Test that InstanceManager.get creates and returns a new instance for BaseComponent-derived objects.
"""
instance = InstanceManager.get(session, instance_id, instance_type=MockBaseComponent)
assert isinstance(instance, MockBaseComponent)
assert instance._id == instance_id
def test_get_creates_new_base_component_instance_if_not_exists_with_extra_value(session, instance_id):
"""
Test that InstanceManager.get creates and returns a new instance for BaseComponent-derived objects.
extra_param are passed to the constructor.
"""
instance = InstanceManager.get(session, instance_id, instance_type=MockBaseComponent, extra_param="value")
assert isinstance(instance, MockBaseComponent)
assert instance._id == instance_id
assert instance.kwargs.get("extra_param") == "value"
def test_get_returns_existing_instance(session, instance_id):
"""
Test that InstanceManager.get returns an existing instance if already registered.
"""
instance1 = InstanceManager.get(session, instance_id, instance_type=MockInstance)
instance2 = InstanceManager.get(session, instance_id)
assert instance1 == instance2
assert instance1 is instance2 # Same reference
def test_register_registers_instance(session, base_component_instance):
"""
Test that InstanceManager.register properly stores an instance.
"""
InstanceManager.register(session, base_component_instance)
key = (session[SESSION_ID_KEY], base_component_instance._id)
assert key in InstanceManager._instances
assert InstanceManager._instances[key] == base_component_instance
def test_register_raises_value_error_when_no_id_provided():
"""
Test that InstanceManager.register raises ValueError if no instance_id is given
and the instance does not have an '_id' attribute.
"""
instance = object() # Instance with no `_id` and not a valid `instance_id`
with pytest.raises(ValueError, match="`instance_id` is not provided"):
InstanceManager.register(None, instance)
def test_register_fetches_id_from_instance_attribute(session):
"""
Test that InstanceManager.register fetches the instance_id from the '_id' attribute of the instance.
"""
class MockInstanceWithId:
_id = "dynamic_id"
instance = MockInstanceWithId()
InstanceManager.register(session, instance)
key = (session[SESSION_ID_KEY], instance._id) # `_id` value taken from the instance
assert key in InstanceManager._instances
assert InstanceManager._instances[key] == instance
def test_register_many_without_session():
"""
Test that InstanceManager.register_many registers all provided instances even with a None session.
"""
instance1 = MockInstance("id1")
instance2 = MockInstance("id2")
InstanceManager.register_many(instance1, instance2)
key1 = (NOT_LOGGED, "id1")
key2 = (NOT_LOGGED, "id2")
assert key1 in InstanceManager._instances
assert InstanceManager._instances[key1] == instance1
assert key2 in InstanceManager._instances
assert InstanceManager._instances[key2] == instance2
def test_remove_registered_instance(session, instance_id):
"""
Test that InstanceManager.remove removes an instance if it exists.
"""
instance = MockInstance(instance_id)
InstanceManager.register(session, instance)
InstanceManager.remove(session, instance_id)
key = (session[SESSION_ID_KEY], instance_id)
assert key not in InstanceManager._instances
def test_remove_with_dispose_method(session, instance_id):
"""
Register and remove an instance with a dispose method
"""
instance = MockInstanceWithDispose(instance_id)
InstanceManager.register(session, instance)
InstanceManager.remove(session, instance_id)
assert hasattr(instance, "disposed") and instance.disposed
key = (session[SESSION_ID_KEY], instance_id)
assert key not in InstanceManager._instances
def test_clear_clears_all_instances(session, instance_id):
"""
Test that InstanceManager.clear removes all registered instances.
"""
instance = MockInstance(instance_id)
InstanceManager.register(session, instance)
InstanceManager.clear()
assert not InstanceManager._instances
def test_get_session_id_returns_logged_in_user_id(session):
"""
Test that _get_session_id extracts the session ID correctly.
"""
session_id = InstanceManager.get_session_id(session)
assert session_id == session[SESSION_ID_KEY]
def test_get_session_id_returns_default_logged_out_value():
"""
Test that _get_session_id returns NOT_LOGGED when session is None.
"""
session_id = InstanceManager.get_session_id(None)
assert session_id == NOT_LOGGED

View File

@@ -0,0 +1,271 @@
import json
from datetime import datetime
import pandas as pd
import pytest
from fasthtml.common import *
from starlette.testclient import TestClient
from components.datagrid.DataGrid import datagrid_app, DataGrid, DATAGRID_PATH, DG_COLUMNS, \
DG_AGGREGATE_FILTERED_SUM, DG_AGGREGATE_SUM
from helpers import get_from_html, matches, extract_table_values, search_elements_by_name, extract_footer_values
@pytest.fixture()
def client():
return TestClient(datagrid_app)
@pytest.fixture()
def datagrid():
df = pd.DataFrame({
'Name': ['Alice', 'Bob'],
'Age': [20, 25]
})
return DataGrid(df, id="testing_grid_id")
@pytest.fixture()
def datagrid2():
df = pd.DataFrame({
'Name': ['Alice', 'Bob', 'Charlie', 'David'],
'Age': [20, 25, 30, 35],
'City': ['New York', 'Los Angeles', 'Chicago', 'Houston'],
'Student': [True, False, True, False],
'Date': [datetime(2020, 1, 10), datetime(2022, 10, 1), datetime(2007, 1, 1), datetime(2000, 12, 31), ]
})
return DataGrid(df, id="testing_grid_id")
@pytest.fixture()
def grid_id(datagrid):
return datagrid.get_grid_id()
def test_i_can_render_datagrid(client, datagrid, grid_id):
res = client.get(f"/{grid_id}")
assert res.status_code == 200
actual = get_from_html(res.text, "body").children[0]
expected = Div(
Div(), # container for keyboard handlers
Div(
Div(), # tooltip
Div(id=f"fa_{grid_id}"), # filter component
Div(), # reset filter, resize, ...
cls="flex justify-between"),
Div(
Div(), # selection management
Div(), # drag and drop indicator
Div(id=f"tcdd_{grid_id}"), # cell drop down
Div(), # keyboard navigation
Div(
Div(id=f"th_{grid_id}"),
Div(id=f"tb_{grid_id}"),
Div(id=f"tf_{grid_id}"),
),
Script(),
id=f"t_{grid_id}",
cls="dt-table",
)
)
assert matches(actual, expected)
def test_i_can_get_filter_popup(client, datagrid, grid_id):
res = client.post("/filter_popup", data=dict(g_id=grid_id, c_id="age"))
assert res.status_code == 200
actual = get_from_html(res.text, "body").children[0]
expected = Div(
Div(Label(Input(type="checkbox", value="__all__")), Input(placeholder='Filter...')),
Div(cls="divider my-1"),
Div(cls="dt-filter-popup-content"),
Div(cls="divider my-1"),
Button(hx_post=f"{DATAGRID_PATH}/filter"),
)
assert matches(actual, expected)
def test_i_can_upload_a_file(client, datagrid, grid_id, excel_file_content):
res = client.post("/upload", data=dict(g_id=grid_id), files=dict(file=("test.xlsx", excel_file_content)))
assert res.status_code == 200
actual = get_from_html(res.text, "body").children[0]
expected = Div(
Div(), # selection management
Div(), # drag and drop indicator
Div(id=f"tcdd_{grid_id}"), # cell drop down
Div(), # keyboard navigation
Div(cls="dt-inner-table"),
Script(),
id=f"t_{grid_id}",
cls="dt-table"
)
assert matches(actual, expected)
table_values = extract_table_values(actual)
assert table_values == {
'Column 1': ['Aba', 'Johan', 'Kodjo'],
'Column 2': ['Female', 'Male', 'Male']
}
def test_i_can_sort(client, datagrid, grid_id):
res = client.post("/sort", data=dict(g_id=grid_id, c_id="age"))
assert res.status_code == 200
body = get_from_html(res.text, "body")
actual = body.children
expected = [
Div(id=f"tb_{grid_id}"),
Div(id=f"tsm_{grid_id}"),
Div(id=f"cmrm_{grid_id}"),
Div(id=f"cmcm_{grid_id}"),
Div(hx_swap_oob='true', id=f"tsi_{grid_id}_name"), # update age sort icon
Div(hx_swap_oob='true', id=f"tsi_{grid_id}_age"), # update age sort icon
]
assert matches(actual, expected)
table_values = extract_table_values(actual[0], header=False)
assert table_values == [
['Alice', '20'],
['Bob', '25']
]
# Sort a second time to change the order
res = client.post("/sort", data=dict(g_id=grid_id, c_id="age"))
assert res.status_code == 200
body = get_from_html(res.text, "body")
actual = body.children
expected = [
Div(id=f"tb_{grid_id}"),
Div(id=f"tsm_{grid_id}"),
Div(id=f"cmrm_{grid_id}"),
Div(id=f"cmcm_{grid_id}"),
Div(hx_swap_oob='true', id=f"tsi_{grid_id}_name"),
Div(hx_swap_oob='true', id=f"tsi_{grid_id}_age"),
]
assert matches(actual, expected)
table_values = extract_table_values(actual[0], header=False)
assert table_values == [
['Bob', '25'],
['Alice', '20'],
]
def test_i_upload_and_then_sort(client, datagrid, grid_id, excel_file_content_2):
res = client.post("/upload", data=dict(g_id=grid_id), files=dict(file=("test.xlsx", excel_file_content_2)))
assert res.status_code == 200
res = client.post("/sort", data=dict(g_id=grid_id, c_id="column_1"))
assert res.status_code == 200
body = get_from_html(res.text, "body")
actual = body.children
table_values = extract_table_values(actual[0], header=False)
assert table_values == [
['A', '2'],
['B', '3'],
['C', '1'],
]
def test_sort_icons_are_reset_when_sort_column_is_changed(client, datagrid, grid_id):
res = client.post("/sort", data=dict(g_id=grid_id, c_id="age"))
assert res.status_code == 200
body = get_from_html(res.text, "body")
actual = body.children
expected_length = 6
expected_index = 4
assert len(actual) == expected_length
assert actual[expected_index].attrs["id"] == f'tsi_{grid_id}_name'
assert actual[expected_index].attrs["hx-swap-oob"] == "true"
assert len(actual[expected_index].children) == 0 # No SVG icon
assert actual[expected_index + 1].attrs["id"] == f'tsi_{grid_id}_age'
assert actual[expected_index + 1].attrs["hx-swap-oob"] == "true"
assert len(actual[expected_index + 1].children) > 0 # there is the SVG icon
res = client.post("/sort", data=dict(g_id=grid_id, c_id="name")) # change the sort
assert res.status_code == 200
body = get_from_html(res.text, "body")
actual = body.children
assert len(actual) == expected_length
assert actual[expected_index].attrs["id"] == f'tsi_{grid_id}_name'
assert actual[expected_index].attrs["hx-swap-oob"] == "true"
assert len(actual[expected_index].children) > 0
assert actual[expected_index + 1].attrs["id"] == f'tsi_{grid_id}_age'
assert actual[expected_index + 1].attrs["hx-swap-oob"] == "true"
assert len(actual[expected_index + 1].children) == 0
def test_footer_is_updated_when_filtering(client):
df = pd.DataFrame({
'Name': ['Alice', 'Bob', 'Charlie', 'David'],
'Age': [20, 25, 30, 35],
})
grid_settings = {
DG_COLUMNS: {
"name": {
"index": 0,
"title": "Name",
},
"age": {
"index": 1,
"title": "Age",
"agg_func": DG_AGGREGATE_FILTERED_SUM,
"agg_func_2": DG_AGGREGATE_SUM,
}}, }
dg = DataGrid(df, id="testing_grid_id", grid_settings=grid_settings)
dg_grid_id = dg.get_grid_id()
res = client.post("/filter", data=dict(g_id=dg_grid_id, c_id="name", f=json.dumps(['Alice', 'Bob', 'Charlie'])))
assert res.status_code == 200
body = get_from_html(res.text, "body").children[0]
values = extract_footer_values(body)
assert values == [[None, '75'],
[None, '110']]
actual = search_elements_by_name(body, "div", {"id": f"tf_{dg_grid_id}"})
expected = [Div(
Div(
Div(Div(), data_col="name"),
Div(Div("75"), data_col="age"),
cls="dt-row dt-footer",
),
Div(
Div(Div(), data_col="name"),
Div(Div("110"), data_col="age"),
cls="dt-row dt-footer",
),
cls="dt-table-footer",
id=f"tf_{dg_grid_id}",
)]
assert matches(actual, expected)
def test_i_can_upload_a_file_and_then_select_a_column(client, datagrid, grid_id, excel_file_content):
res = client.post("/upload", data=dict(g_id=grid_id), files=dict(file=("test.xlsx", excel_file_content)))
assert res.status_code == 200
res = client.post("/on_click", data=dict(g_id=grid_id, col_index="0", modifier=""))
assert res.status_code == 200

157
tests/test_repositories.py Normal file
View File

@@ -0,0 +1,157 @@
import pytest
from fasthtml.components import *
from components.addstuff.constants import ROUTE_ROOT, Routes
from components.addstuff.settings import Repository, MyTable, AddStuffSettings
from core.settings_management import SettingsManager, MemoryDbEngine
from helpers import matches, StartsWith, div_icon, find_first_match, search_elements_by_path
from src.components.addstuff.components.Repositories import Repositories
USER_EMAIL = "test@mail.com"
USER_ID = "test_user"
TEST_REPOSITORIES_ID = "testing_grid_id"
@pytest.fixture
def session():
return {"user_id": USER_ID, "user_email": USER_EMAIL}
@pytest.fixture
def tabs_manager():
class MockTabsManager:
def __init__(self):
self.tabs = []
self.mock_content = Div("No tabs yet")
self._called_methods: list[tuple] = []
def add_tab(self, *args, **kwargs):
self._called_methods.append(("set_tab_content", args, kwargs))
table_name, content, key = args
self.tabs.append({"table_name": table_name, "content": content, "key": key})
def get_id(self):
return "tabs_id"
def set_tab_content(self, *args, **kwargs):
self._called_methods.append(("set_tab_content", args, kwargs))
title = kwargs.get("title", None)
key = kwargs.get("key", None)
self.mock_content = Div(f"{title=}, {key=}")
def refresh(self):
return self.mock_content
return MockTabsManager()
@pytest.fixture
def db_engine():
return MemoryDbEngine()
@pytest.fixture
def repositories(session, tabs_manager, db_engine):
return Repositories(session=session, _id=TEST_REPOSITORIES_ID,
settings_manager=SettingsManager(engine=db_engine),
tabs_manager=tabs_manager)
def test_render_no_repository(repositories):
actual = repositories.__ft__()
expected = (
Div(
Div(id=f"tt_{repositories.get_id()}"),
Div(cls="divider"),
Div("Repositories"),
Div(id=repositories.get_id()),
Script()
)
)
assert matches(actual, expected)
def test_render_when_repo_and_tables(db_engine, repositories):
db_engine.init_db(USER_ID, 'AddStuffSettings', AddStuffSettings([
Repository("repo 1", [MyTable("table 1"), MyTable("table 2")]),
Repository("repo 2", [MyTable("table 3")]),
]))
actual = repositories.__ft__()
to_compare = search_elements_by_path(actual, "div", {"id": repositories.get_id()})[0]
expected = Div(
Div(
Input(type="radio"),
Div(div_icon("database"), Div("repo 1"), cls=StartsWith("collapse-title")),
Div(
Div(div_icon("table"), Div("table 1")),
Div(div_icon("table"), Div("table 2")),
cls=StartsWith("collapse-content")
),
),
Div(
Input(type="radio"),
Div(div_icon("database"), Div("repo 2"), cls=StartsWith("collapse-title")),
Div(
Div(div_icon("table"), Div("table 3")),
cls=StartsWith("collapse-content")
),
),
id=repositories.get_id())
assert matches(to_compare, expected)
def test_i_can_add_new_repository(repositories):
tab_id = "tab_id"
form_id = "form_id"
repository_name = "repository_name"
table_name = "table_name"
res = repositories.add_new_repository(tab_id, form_id, repository_name, table_name)
expected = (
Div(
Input(type="radio"),
Div(div_icon("database"), Div(repository_name)),
Div(
Div(div_icon("table"), Div(table_name)),
cls=StartsWith("collapse-content")
),
),
Div(f"title='{table_name}', key={(repository_name, table_name)}")
)
assert matches(res, expected)
def test_i_can_click_on_repo(db_engine, repositories):
db_engine.init_db(USER_ID, 'AddStuffSettings', AddStuffSettings([
Repository("repo 1", [])
]))
actual = repositories.__ft__()
expected = Input(
hx_put=f"{ROUTE_ROOT}{Routes.SelectRepository}",
hx_vals=f'{{"_id": "{repositories.get_id()}", "repository": "repo 1"}}',
)
to_compare = find_first_match(actual, "div.div.div.input")
assert matches(to_compare, expected)
def test_render_i_can_click_on_table(db_engine, repositories, tabs_manager):
db_engine.init_db(USER_ID, 'AddStuffSettings', AddStuffSettings([
Repository("repo 1", [MyTable("table 1")])
]))
actual = repositories.__ft__()
expected = Div(name="repo-table",
hx_get=f"{ROUTE_ROOT}{Routes.ShowTable}",
hx_target=f"#{repositories.tabs_manager.get_id()}",
hx_swap="outerHTML",
hx_vals=f'{{"_id": "{repositories.get_id()}", "repository": "repo 1", "table": "table 1"}}',
cls="flex")
to_compare = find_first_match(actual, "div.div.div.div.div[name='repo-table']")
assert matches(to_compare, expected)

325
tests/test_serializer.py Normal file
View File

@@ -0,0 +1,325 @@
import dataclasses
import datetime
import hashlib
import pickle
from enum import Enum
import pandas as pd
import pytest
from core.serializer import TAG_TUPLE, TAG_SET, Serializer, TAG_OBJECT, TAG_ID, TAG_REF
from core.settings_objects import BudgetTrackerFile, BudgetTrackerFiles
class Obj:
def __init__(self, a, b, c):
self.a = a
self.b = b
self.c = c
def __eq__(self, other):
if id(self) == id(other):
return True
if not isinstance(other, Obj):
return False
return self.a == other.a and self.b == other.b and self.c == other.c
def __hash__(self):
return hash((self.a, self.b, self.c))
class Obj2:
class InnerClass:
def __init__(self, x):
self.x = x
def __eq__(self, other):
if not isinstance(other, Obj2.InnerClass):
return False
return self.x == other.x
def __hash__(self):
return hash(self.x)
def __init__(self, a, b, x):
self.a = a
self.b = b
self.x = Obj2.InnerClass(x)
def __eq__(self, other):
if not isinstance(other, Obj2):
return False
return (self.a == other.a and
self.b == other.b and
self.x == other.x)
def __hash__(self):
return hash((self.a, self.b))
class ObjEnum(Enum):
A = 1
B = "second"
C = "last"
@dataclasses.dataclass
class DummyComplexClass:
prop1: str
prop2: Obj
prop3: ObjEnum
class DummyRefHelper:
"""
When something is too complicated to serialize, we just default to pickle
That is what this helper class is doing
"""
def __init__(self):
self.refs = {}
def save_ref(self, obj):
sha256_hash = hashlib.sha256()
pickled_data = pickle.dumps(obj)
sha256_hash.update(pickled_data)
digest = sha256_hash.hexdigest()
self.refs[digest] = pickled_data
return digest
def load_ref(self, digest):
return pickle.loads(self.refs[digest])
@pytest.mark.parametrize("obj, expected", [
(1, 1),
(3.14, 3.14),
("a string", "a string"),
(True, True),
(None, None),
([1, 3.14, "a string"], [1, 3.14, "a string"]),
((1, 3.14, "a string"), {TAG_TUPLE: [1, 3.14, "a string"]}),
({1}, {TAG_SET: [1]}),
({"a": "a", "b": 3.14, "c": True}, {"a": "a", "b": 3.14, "c": True}),
({1: "a", 2: 3.14, 3: True}, {1: "a", 2: 3.14, 3: True}),
([1, [3.14, "a string"]], [1, [3.14, "a string"]]),
([1, (3.14, "a string")], [1, {TAG_TUPLE: [3.14, "a string"]}]),
([], []),
])
def test_i_can_flatten_and_restore_primitives(obj, expected):
serializer = Serializer()
flatten = serializer.serialize(obj)
assert flatten == expected
decoded = serializer.deserialize(flatten)
assert decoded == obj
def test_i_can_flatten_and_restore_instances():
serializer = Serializer()
obj1 = Obj(1, "b", True)
obj2 = Obj(3.14, ("a", "b"), obj1)
flatten = serializer.serialize(obj2)
assert flatten == {TAG_OBJECT: 'tests.test_serializer.Obj',
'a': 3.14,
'b': {TAG_TUPLE: ['a', 'b']},
'c': {TAG_OBJECT: 'tests.test_serializer.Obj',
'a': 1,
'b': 'b',
'c': True}}
decoded = serializer.deserialize(flatten)
assert decoded == obj2
def test_i_can_flatten_and_restore_enum():
serializer = Serializer()
obj1 = ObjEnum.A
obj2 = ObjEnum.B
obj3 = ObjEnum.C
wrapper = {
"a": obj1,
"b": obj2,
"c": obj3,
"d": obj1
}
flatten = serializer.serialize(wrapper)
assert flatten == {'a': {'__enum__': 'tests.test_serializer.ObjEnum.A'},
'b': {'__enum__': 'tests.test_serializer.ObjEnum.B'},
'c': {'__enum__': 'tests.test_serializer.ObjEnum.C'},
'd': {'__id__': 0}}
decoded = serializer.deserialize(flatten)
assert decoded == wrapper
def test_i_can_flatten_and_restore_list_with_enum():
serializer = Serializer()
obj = [DummyComplexClass("a", Obj(1, "a", ObjEnum.A), ObjEnum.A),
DummyComplexClass("b", Obj(2, "b", ObjEnum.B), ObjEnum.B),
DummyComplexClass("c", Obj(3, "c", ObjEnum.C), ObjEnum.B)]
flatten = serializer.serialize(obj)
assert flatten == [{'__object__': 'tests.test_serializer.DummyComplexClass',
'prop1': 'a',
'prop2': {'__object__': 'tests.test_serializer.Obj',
'a': 1,
'b': 'a',
'c': {'__enum__': 'tests.test_serializer.ObjEnum.A'}},
'prop3': {'__id__': 2}},
{'__object__': 'tests.test_serializer.DummyComplexClass',
'prop1': 'b',
'prop2': {'__object__': 'tests.test_serializer.Obj',
'a': 2,
'b': 'b',
'c': {'__enum__': 'tests.test_serializer.ObjEnum.B'}},
'prop3': {'__id__': 5}},
{'__object__': 'tests.test_serializer.DummyComplexClass',
'prop1': 'c',
'prop2': {'__object__': 'tests.test_serializer.Obj',
'a': 3,
'b': 'c',
'c': {'__enum__': 'tests.test_serializer.ObjEnum.C'}},
'prop3': {'__id__': 5}}]
decoded = serializer.deserialize(flatten)
assert decoded == obj
def test_i_can_manage_circular_reference():
serializer = Serializer()
obj1 = Obj(1, "b", True)
obj1.c = obj1
flatten = serializer.serialize(obj1)
assert flatten == {TAG_OBJECT: 'tests.test_serializer.Obj',
'a': 1,
'b': 'b',
'c': {TAG_ID: 0}}
decoded = serializer.deserialize(flatten)
assert decoded.a == obj1.a
assert decoded.b == obj1.b
assert decoded.c == decoded
def test_i_can_use_refs_on_primitive():
serializer = Serializer(DummyRefHelper())
obj1 = Obj(1, "b", True)
flatten = serializer.serialize(obj1, ["c"])
assert flatten == {TAG_OBJECT: 'tests.test_serializer.Obj',
'a': 1,
'b': 'b',
'c': {TAG_REF: '112bda3b495d867b6a98c899fac7c25eb60ca4b6e6fe5ec7ab9299f93e8274bc'}}
decoded = serializer.deserialize(flatten)
assert decoded == obj1
def test_i_can_use_refs_on_path():
serializer = Serializer(DummyRefHelper())
obj1 = Obj(1, "b", True)
obj2 = Obj(1, "b", obj1)
flatten = serializer.serialize(obj2, ["c.b"])
assert flatten == {TAG_OBJECT: 'tests.test_serializer.Obj',
'a': 1,
'b': 'b',
'c': {TAG_OBJECT: 'tests.test_serializer.Obj',
'a': 1,
'b': {TAG_REF: '897f2e2b559dd876ad870c82283197b8cfecdf84736192ea6fb9ee5a5080a3a4'},
'c': True}}
decoded = serializer.deserialize(flatten)
assert decoded == obj2
def test_can_use_refs_when_circular_reference():
serializer = Serializer(DummyRefHelper())
obj1 = Obj(1, "b", True)
obj1.c = obj1
flatten = serializer.serialize(obj1, ["c"])
assert flatten == {TAG_OBJECT: 'tests.test_serializer.Obj',
'a': 1,
'b': 'b',
'c': {TAG_REF: "87b1980d83bd267e2c8cc2fbc435ba00349e45b736c40f3984f710ebb4495adc"}}
decoded = serializer.deserialize(flatten)
assert decoded.a == obj1.a
assert decoded.b == obj1.b
assert decoded.c == decoded
def test_i_can_manage_implicit_use_refs():
data = {'Key': ['A'], 'Value': [0.1]}
obj = BudgetTrackerFile(2024, 8, data=pd.DataFrame(data))
serializer = Serializer(DummyRefHelper())
flatten = serializer.serialize(obj) # use_refs is not indicated. It will be found browsing the objs
assert flatten == {TAG_OBJECT: 'core.settings_objects.BudgetTrackerFile',
'month': 8,
'year': 2024,
'file_name': None,
'grid_settings': None,
'sheet_name': None,
'data': {TAG_REF: "0d523d048ce02198a511c8e647103d89f41da23dcf90127a5be7d62097f64079"}}
def test_i_can_manage_implicit_use_refs_in_sub_objects():
data1 = {'Key': ['A'], 'Value': [0.1]}
sub_obj1 = BudgetTrackerFile(2024, 8, data=pd.DataFrame(data1))
data2 = {'Key': ['B'], 'Value': [0.2]}
sub_obj2 = BudgetTrackerFile(2024, 8, data=pd.DataFrame(data2))
obj = BudgetTrackerFiles([sub_obj1, sub_obj2])
serializer = Serializer(DummyRefHelper())
flatten = serializer.serialize(obj) # use_refs is not indicated. It will be found browsing the objs
assert flatten == {TAG_OBJECT: 'core.settings_objects.BudgetTrackerFiles',
'files': [{TAG_OBJECT: 'core.settings_objects.BudgetTrackerFile',
'data': {
TAG_REF: '0d523d048ce02198a511c8e647103d89f41da23dcf90127a5be7d62097f64079'},
'month': 8,
'year': 2024,
'file_name': None,
'grid_settings': None,
'sheet_name': None,
},
{TAG_OBJECT: 'core.settings_objects.BudgetTrackerFile',
'data': {
TAG_REF: '51fa88e6f200da3860693e7f7765e444c55f5d4a3b6b073d9b1a9bddd742247e'},
'month': 8,
'year': 2024,
'file_name': None,
'grid_settings': None,
'sheet_name': None,
}]}
def test_i_can_serialize_date():
obj = datetime.date.today()
serializer = Serializer()
flatten = serializer.serialize(obj)
decoded = serializer.deserialize(flatten)
assert decoded == obj
# def test_i_can_manage_sub_class():
# obj2 = Obj2("a", "b", "x")
# serializer = Serializer()
#
# flatten = serializer.serialize(obj2)
#
# decoded = serializer.deserialize(flatten)
#
# assert decoded == obj2

View File

@@ -0,0 +1,116 @@
from unittest.mock import MagicMock
import pytest
from core.settings_management import SettingsManager, DummyDbEngine
from core.settings_objects import BudgetTrackerSettings, BudgetTrackerMappings, BUDGET_TRACKER_MAPPINGS_ENTRY
FAKE_USER_ID = "FakeUserId"
@pytest.fixture()
def manager():
return SettingsManager(DummyDbEngine("settings_from_unit_testing.json"))
def test_i_can_save_and_load_settings(manager):
settings = BudgetTrackerSettings()
manager.save(FAKE_USER_ID, "MyEntry", settings)
from_db = manager.load(FAKE_USER_ID, "MyEntry")
assert isinstance(from_db, BudgetTrackerSettings)
assert from_db.spread_sheet == settings.spread_sheet
assert from_db.col_row_num == settings.col_row_num
assert from_db.col_project == settings.col_project
assert from_db.col_owner == settings.col_owner
assert from_db.col_capex == settings.col_capex
assert from_db.col_details == settings.col_details
assert from_db.col_supplier == settings.col_supplier
assert from_db.col_budget_amt == settings.col_budget_amt
assert from_db.col_actual_amt == settings.col_actual_amt
assert from_db.col_forecast5_7_amt == settings.col_forecast5_7_amt
@pytest.fixture
def mock_db_engine():
"""Fixture to mock the _db_engine instance."""
return MagicMock()
@pytest.fixture
def settings_manager(mock_db_engine):
"""Fixture to provide an instance of SettingsManager with a mocked db engine."""
return SettingsManager(engine=mock_db_engine)
def test_get_successful(settings_manager, mock_db_engine):
"""Test successful retrieval of a value."""
# Arrange
session = {"user_id": "user123", "user_email": "user@example.com"}
mock_db_engine.get.return_value = "mock_value"
# Act
result = settings_manager.get(session=session, key="theme")
# Assert
assert result == "mock_value"
mock_db_engine.get.assert_called_once_with("user@example.com", "user123", "theme")
def test_get_key_error_no_default(settings_manager, mock_db_engine):
"""Test KeyError is raised if key doesn't exist and default is NoDefault."""
# Arrange
session = {"user_id": "user123", "user_email": "user@example.com"}
mock_db_engine.get.side_effect = KeyError # Simulate missing key
# Act & Assert
with pytest.raises(KeyError):
settings_manager.get(session=session, key="theme")
def test_get_key_error_with_default(settings_manager, mock_db_engine):
"""Test default value is returned if key doesn't exist and default is provided."""
# Arrange
session = {"user_id": "user123", "user_email": "user@example.com"}
mock_db_engine.get.side_effect = KeyError # Simulate missing key
# Act
result = settings_manager.get(session=session, key="theme", default="default_value")
# Assert
assert result == "default_value"
mock_db_engine.get.assert_called_once_with("user@example.com", "user123", "theme")
def test_get_key_none(settings_manager, mock_db_engine):
"""Test behavior when key is None."""
# Arrange
session = {"user_id": "user123", "user_email": "user@example.com"}
mock_db_engine.get.return_value = {"example_key": "example_value"}
# Act
result = settings_manager.get(session=session, key=None)
# Assert
assert result == {"example_key": "example_value"}
mock_db_engine.get.assert_called_once_with("user@example.com", "user123", None)
#
# def test_i_can_save_and_load_mapping_settings(manager):
# """
# I test 'BudgetTrackerMappings' because there is an object inside an object
# :param manager:
# :return:
# """
# settings = BudgetTrackerMappings(mappings=[
# BudgetTrackerMappings.Mapping(1, "p1", "o1", "d1", "s1", "l1_1", "l2_1", "l3_1", 0),
# BudgetTrackerMappings.Mapping(1, "p2", "o2", "d2", "s2", "l1_2", "l2_2", "l3_2", 10)])
#
# manager.save(FAKE_USER_ID, BUDGET_TRACKER_MAPPINGS_ENTRY, settings)
# from_db = manager.load(FAKE_USER_ID, BUDGET_TRACKER_MAPPINGS_ENTRY)
#
# assert isinstance(from_db, BudgetTrackerMappings)
# assert len(from_db.mappings) == 2
# assert isinstance(from_db.mappings[0], BudgetTrackerMappings.Mapping)
# assert from_db.mappings[0].col_project == "p1"
# assert from_db.mappings[1].col_project == "p2"

View File

@@ -0,0 +1,47 @@
from core.settings_objects import BudgetTrackerSettings
def test_i_a_can_as_dict():
settings = BudgetTrackerSettings()
assert settings.as_dict() == {'col_actual_amt': 'AO',
'col_budget_amt': 'BQ',
'col_capex': 'G',
'col_details': 'H',
'col_forecast5_7_amt': 'BC',
'col_owner': 'E',
'col_project': 'D',
'col_row_num': 'A',
'col_supplier': 'I',
'spread_sheet': 'full charges'}
def test_i_can_load_from_dict():
as_dict = {'spread_sheet': 'spread_sheet',
'col_actual_amt': 'actual',
'col_budget_amt': 'budget',
'col_capex': 'capex',
'col_details': 'details',
'col_forecast5_7_amt': 'forecast5_7',
'col_index': 'index',
'col_owner': 'owner',
'col_project': 'project',
'col_supplier': 'supplier'}
settings = BudgetTrackerSettings().from_dict(as_dict)
assert settings.spread_sheet == "spread_sheet"
assert settings.col_actual_amt == "actual"
assert settings.col_budget_amt == "budget"
assert settings.col_capex == "capex"
assert settings.col_details == "details"
assert settings.col_forecast5_7_amt == "forecast5_7"
assert settings.col_index == "index"
assert settings.col_owner == "owner"
assert settings.col_project == "project"
assert settings.col_supplier == "supplier"
def test_i_can_as_formatted_dict():
settings = BudgetTrackerSettings()
assert settings.get_display_name("spread_sheet") == 'Spread Sheet'
assert settings.get_display_name("col_forecast5_7_amt") == 'Forecast 5+7'

211
tests/test_tabs.py Normal file
View File

@@ -0,0 +1,211 @@
import pytest
from fastcore.basics import NotStr
from fasthtml.components import *
from components.tabs.components.MyTabs import Tab, MyTabs
from components.tabs.constants import ROUTE_ROOT, Routes
from tests.helpers import matches, find_first_match
@pytest.fixture
def tabs_instance():
"""Fixture to create an instance of tabs_instance with a mock session and ID."""
session = {'user_id': 'test_user'}
_id = 'test_tabs'
return MyTabs(session, _id)
def test_initial_tabs_empty(tabs_instance):
"""Test that the tabs are initially empty."""
assert len(tabs_instance.tabs) == 0
def test_select_tab_empty(tabs_instance):
"""Test the select_tab method when no tabs are available."""
tabs_instance.select_tab_by_id("1") # Tab ID "1" doesn't exist
assert len(tabs_instance.tabs) == 0 # No changes should occur
def test_add_tab_and_select_tab(tabs_instance):
"""Test adding tabs and using the select_tab method."""
# Add some tabs
tabs_instance.tabs = [
Tab("1", "Tab1", "Content 1"),
Tab("2", "Tab2", "Content 2", active=True),
Tab("3", "Tab3", "Content 3"),
]
# Select a specific tab
tabs_instance.select_tab_by_id("3")
assert tabs_instance.tabs[0].active is False
assert tabs_instance.tabs[1].active is False
assert tabs_instance.tabs[2].active is True
def test_add_tab_creates_a_new_tab(tabs_instance):
title = "Test Tab"
content = "This is a test content."
tab_id = tabs_instance.add_tab(title=title, content=content)
assert len(tabs_instance.tabs) == 1
assert tabs_instance.tabs[0].id == tab_id
assert tabs_instance.tabs[0].title == title
assert tabs_instance.tabs[0].content == content
def test_add_tab_with_key_replaces_existing_tab(tabs_instance):
key = "test_key"
title1 = "Tab 1"
content1 = "Content 1"
title2 = "Tab 2"
content2 = "Content 2"
tab_id1 = tabs_instance.add_tab(title=title1, content=content1, key=key)
tab_id2 = tabs_instance.add_tab(title=title2, content=content2, key=key)
assert tab_id1 == tab_id2
assert len(tabs_instance.tabs) == 1
assert tabs_instance.tabs[0].title == title2
assert tabs_instance.tabs[0].content == content2
def test_add_tab_no_key_creates_unique_tabs(tabs_instance):
title1 = "First Tab"
content1 = "First Tab Content"
title2 = "Second Tab"
content2 = "Second Tab Content"
tab_id1 = tabs_instance.add_tab(title=title1, content=content1)
tab_id2 = tabs_instance.add_tab(title=title2, content=content2)
assert tab_id1 != tab_id2
assert len(tabs_instance.tabs) == 2
assert tabs_instance.tabs[0].id == tab_id1
assert tabs_instance.tabs[1].id == tab_id2
def test_add_tab_selects_new_tab_active(tabs_instance):
title = "Active Tab"
content = "Active Tab Content"
tabs_instance.add_tab(title=title, content=content)
assert tabs_instance.get_active_tab_content() == content
def test_add_tab_with_icon_attribute(tabs_instance):
title = "Tab With Icon"
content = "Tab Content"
icon = "test_icon_path"
tab_id = tabs_instance.add_tab(title=title, content=content, icon=icon)
assert len(tabs_instance.tabs) == 1
assert tabs_instance.tabs[0].id == tab_id
assert tabs_instance.tabs[0].icon == icon
def test_remove_tab(tabs_instance):
"""Test the remove_tab method."""
# Add some tabs
tabs_instance.tabs = [
Tab("1", "Tab1", "Content 1"),
Tab("2", "Tab2", "Content 2", active=True),
Tab("3", "Tab3", "Content 3"),
]
# Remove a tab
tabs_instance.remove_tab("2")
assert len(tabs_instance.tabs) == 2
assert tabs_instance.tabs[0].id == "1"
assert tabs_instance.tabs[1].id == "3"
assert all(tab.id != "2" for tab in tabs_instance.tabs)
def test_get_active_tab_content(tabs_instance):
"""Test the get_active_tab_content method."""
# Initially, no content should be active (empty tabs list)
assert tabs_instance.get_active_tab_content() is None
# Add some tabs
tabs_instance.tabs = [
Tab("1", "Tab1", "Content 1"),
Tab("2", "Tab2", "Content 2", active=True),
Tab("3", "Tab3", "Content 3"),
]
# Verify the content of the active tab
assert tabs_instance.get_active_tab_content() == "Content 2"
# Change the active tab to Tab1 and verify
tabs_instance.select_tab_by_id("1")
assert tabs_instance.get_active_tab_content() == "Content 1"
# Remove all tabs and check active content is None
tabs_instance.tabs.clear()
assert tabs_instance.get_active_tab_content() is None
def test_there_always_an_active_tab_after_removal(tabs_instance):
tabs_instance.tabs = [
Tab("1", "Tab1", "Content 1"),
Tab("2", "Tab2", "Content 2", active=True),
Tab("3", "Tab3", "Content 3"),
]
tabs_instance.remove_tab("2")
assert tabs_instance.tabs[0].active is True
assert tabs_instance.tabs[1].active is False
def test_do_no_change_the_active_tab_if_another_tab_is_removed(tabs_instance):
tabs_instance.tabs = [
Tab("1", "Tab1", "Content 1"),
Tab("2", "Tab2", "Content 2"),
Tab("3", "Tab3", "Content 3", active=True),
]
tabs_instance.remove_tab("1")
assert tabs_instance.tabs[0].active is False
assert tabs_instance.tabs[1].active is True
def test_render_empty_when_empty(tabs_instance):
expected = Div(id=tabs_instance._id)
actual = tabs_instance.__ft__()
assert matches(tabs_instance.__ft__(), expected)
def test_render_empty_when_multiple_tabs(tabs_instance):
tabs_instance.tabs = [
Tab("1", "Tab1", "Content 1"),
Tab("2", "Tab2", "Content 2", active=True),
Tab("3", "Tab3", "Content 3"),
]
expected = Div(
Span(cls="tabs-tab "),
Span(cls="tabs-tab tabs-active"),
Span(cls="tabs-tab "),
Div("Content 2", cls="tabs-content"),
id=tabs_instance._id,
cls="tabs",
)
actual = tabs_instance.__ft__()
assert matches(actual, expected)
def test_render_a_tab_has_label_and_a_cross_with_correct_hx_posts(tabs_instance):
tabs_instance.tabs = [
Tab("1", "Tab1", "Content 1"),
]
expected = Span(
Label("Tab1", hx_post=f"{ROUTE_ROOT}{Routes.SelectTab}"),
Div(NotStr('<svg name="close"'), hx_post=f"{ROUTE_ROOT}{Routes.RemoveTab}"),
cls="tabs-tab "
)
actual = find_first_match(tabs_instance.__ft__(), "div.span")
assert matches(actual, expected)

130
tests/test_utils.py Normal file
View File

@@ -0,0 +1,130 @@
# from utils import column_to_number
#
#
# def test_can_column_to_number():
# assert column_to_number("A") == 1
# assert column_to_number("AA") == 27
# assert column_to_number("ZZZ") == 475254
import pytest
from fasthtml.components import Div
from core.utils import make_html_id, update_elements, snake_case_to_capitalized_words, merge_classes
@pytest.mark.parametrize("string, expected", [
("My Example String!", "My-Example-String_"),
("123 Bad ID", "id_123-Bad-ID"),
(None, None)
])
def test_i_can_have_valid_html_id(string, expected):
assert make_html_id(string) == expected
def test_i_can_update_elements_when_null():
assert update_elements(None, []) is None
def test_i_can_update_elements_when_wrong_id():
div = Div(id="wrong", value="original value")
updates = [{"id": "correct_id", "value": "new value", "method": "value"}]
res = update_elements(div, updates)
assert res.id == "wrong"
assert res.value == "original value"
def test_i_can_update_elements_when_correct_id():
div = Div(id="correct_id", value="original value")
updates = [{"id": "correct_id", "value": "new value", "method": "value"}]
res = update_elements(div, updates)
assert res.id == "correct_id"
assert res.value == "new value"
def test_i_can_update_elements_when_list_of_elements():
div1 = Div(id="correct_id", value="original value")
div2 = Div(id="correct_id", value="original value")
updates = [{"id": "correct_id", "value": "new value", "method": "value"}]
res = update_elements([div1, div2], updates)
assert res[0].id == "correct_id"
assert res[0].value == "new value"
assert res[1].id == "correct_id"
assert res[1].value == "new value"
def test_i_can_update_elements_list_of_elements_with_different_updates():
div1 = Div(id="id1", value="original value")
div2 = Div(id="id2", value="original value")
updates = [{"id": "id1", "value": "value1", "method": "value"},
{"id": "id2", "value": "value2", "method": "value"}, ]
res = update_elements([div1, div2], updates)
assert res[0].id == "id1"
assert res[0].value == "value1"
assert res[1].id == "id2"
assert res[1].value == "value2"
def test_i_can_update_elements_when_children():
child1 = Div(id="id2", value="original value")
child2 = Div(id="wrong id", value="original value")
child3 = Div(id="id3", value="original value")
div1 = Div(child1, child2, child3, id="id1", value="original value")
updates = [{"id": "id1", "value": "value1", "method": "value"},
{"id": "id2", "value": "value2", "method": "value"},
{"id": "id3", "value": "value3", "method": "value"}, ]
res = update_elements(div1, updates)
assert res.id == "id1"
assert res.value == "value1"
assert res.children[0].id == "id2"
assert res.children[0].value == "value2"
assert res.children[1].id == "wrong id"
assert res.children[1].value == "original value"
assert res.children[2].id == "id3"
assert res.children[2].value == "value3"
@pytest.mark.parametrize("snake_case, expected", [
("column", "Column"),
("column_name", "Column Name"),
("this_is_a_column_name", "This Is A Column Name"),
("Column_Name", "Column Name"),
("column__name", "Column Name"),
("This Is A Column Name", "This is a column name"),
])
def test_can_snake_case_to_capitalized_words(snake_case, expected):
assert snake_case_to_capitalized_words(snake_case) == expected
def test_i_can_merge_cls():
# basic use cases
assert merge_classes() is None
assert merge_classes("class1") == "class1"
assert merge_classes("class1", "class2") == "class1 class2"
# using dict
kwargs = {}
assert merge_classes("class1", kwargs) == "class1"
assert kwargs == {}
kwargs = {"foo": "bar"}
assert merge_classes("class1", kwargs) == "class1"
assert kwargs == {"foo": "bar"}
kwargs = {"foo": "bar", "cls": "class2"}
assert merge_classes("class1", kwargs) == "class1 class2"
assert kwargs == {"foo": "bar"}
kwargs = {"foo": "bar", "class": "class2"}
assert merge_classes("class1", kwargs) == "class1 class2"
assert kwargs == {"foo": "bar"}
# using tuples
assert merge_classes("class1", ("class2", "class3")) == "class1 class2 class3"
# values are unique
assert merge_classes("class2", "class1", ("class1", ), {"cls": "class1"}) == "class2 class1"