First Working version. I can add table
This commit is contained in:
66
tests/conftest.py
Normal file
66
tests/conftest.py
Normal 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
BIN
tests/fixtures/Book1.xlsx
vendored
Normal file
Binary file not shown.
549
tests/helpers.py
Normal file
549
tests/helpers.py
Normal 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}"'))
|
||||
144
tests/test_columns_settings.py
Normal file
144
tests/test_columns_settings.py
Normal 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
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
454
tests/test_datagrid_new.py
Normal 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"
|
||||
24
tests/test_datagridcommand_manager.py
Normal file
24
tests/test_datagridcommand_manager.py
Normal 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
181
tests/test_dbengine.py
Normal 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
108
tests/test_dummydbengine.py
Normal 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
331
tests/test_helpers.py
Normal 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]
|
||||
241
tests/test_instance_manager.py
Normal file
241
tests/test_instance_manager.py
Normal 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
|
||||
271
tests/test_integration_datagrid.py
Normal file
271
tests/test_integration_datagrid.py
Normal 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
157
tests/test_repositories.py
Normal 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
325
tests/test_serializer.py
Normal 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
|
||||
116
tests/test_settingsmanager.py
Normal file
116
tests/test_settingsmanager.py
Normal 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"
|
||||
47
tests/test_settingsobjets.py
Normal file
47
tests/test_settingsobjets.py
Normal 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
211
tests/test_tabs.py
Normal 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
130
tests/test_utils.py
Normal 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"
|
||||
Reference in New Issue
Block a user