webwidgets 0.2.0__tar.gz → 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. webwidgets-1.0.0/.gitignore +2 -0
  2. webwidgets-1.0.0/PKG-INFO +66 -0
  3. webwidgets-1.0.0/README.md +53 -0
  4. {webwidgets-0.2.0 → webwidgets-1.0.0}/tests/compilation/test_css.py +144 -69
  5. {webwidgets-0.2.0 → webwidgets-1.0.0}/tests/compilation/test_html_node.py +44 -4
  6. {webwidgets-0.2.0 → webwidgets-1.0.0}/tests/compilation/test_html_tags.py +18 -4
  7. webwidgets-1.0.0/tests/utility/test_indentation.py +29 -0
  8. webwidgets-1.0.0/tests/utility/test_representation.py +118 -0
  9. {webwidgets-0.2.0 → webwidgets-1.0.0}/tests/utility/test_validation.py +65 -1
  10. webwidgets-1.0.0/tests/website/__init__.py +11 -0
  11. webwidgets-1.0.0/tests/website/test_website.py +402 -0
  12. webwidgets-1.0.0/tests/widgets/__init__.py +11 -0
  13. webwidgets-1.0.0/tests/widgets/test_page.py +143 -0
  14. webwidgets-1.0.0/webwidgets/__init__.py +18 -0
  15. {webwidgets-0.2.0/webwidgets → webwidgets-1.0.0/webwidgets/compilation/css}/__init__.py +2 -3
  16. {webwidgets-0.2.0 → webwidgets-1.0.0}/webwidgets/compilation/css/css.py +90 -31
  17. {webwidgets-0.2.0 → webwidgets-1.0.0}/webwidgets/compilation/html/__init__.py +2 -1
  18. {webwidgets-0.2.0 → webwidgets-1.0.0}/webwidgets/compilation/html/html_node.py +45 -5
  19. {webwidgets-0.2.0 → webwidgets-1.0.0}/webwidgets/compilation/html/html_tags.py +38 -1
  20. {webwidgets-0.2.0 → webwidgets-1.0.0}/webwidgets/utility/__init__.py +2 -0
  21. webwidgets-1.0.0/webwidgets/utility/indentation.py +25 -0
  22. webwidgets-1.0.0/webwidgets/utility/representation.py +34 -0
  23. webwidgets-1.0.0/webwidgets/website/__init__.py +14 -0
  24. webwidgets-1.0.0/webwidgets/website/compiled_website.py +34 -0
  25. webwidgets-1.0.0/webwidgets/website/website.py +88 -0
  26. {webwidgets-0.2.0/webwidgets/compilation/css → webwidgets-1.0.0/webwidgets/widgets}/__init__.py +2 -1
  27. webwidgets-1.0.0/webwidgets/widgets/containers/__init__.py +14 -0
  28. webwidgets-1.0.0/webwidgets/widgets/containers/container.py +38 -0
  29. webwidgets-1.0.0/webwidgets/widgets/containers/page.py +59 -0
  30. webwidgets-1.0.0/webwidgets/widgets/widget.py +34 -0
  31. webwidgets-0.2.0/.gitignore +0 -1
  32. webwidgets-0.2.0/PKG-INFO +0 -18
  33. webwidgets-0.2.0/README.md +0 -5
  34. {webwidgets-0.2.0 → webwidgets-1.0.0}/.github/workflows/cd.yml +0 -0
  35. {webwidgets-0.2.0 → webwidgets-1.0.0}/.github/workflows/ci-full.yml +0 -0
  36. {webwidgets-0.2.0 → webwidgets-1.0.0}/.github/workflows/ci-quick.yml +0 -0
  37. {webwidgets-0.2.0 → webwidgets-1.0.0}/LICENSE +0 -0
  38. {webwidgets-0.2.0 → webwidgets-1.0.0}/pyproject.toml +0 -0
  39. {webwidgets-0.2.0 → webwidgets-1.0.0}/tests/__init__.py +0 -0
  40. {webwidgets-0.2.0 → webwidgets-1.0.0}/tests/compilation/__init__.py +0 -0
  41. {webwidgets-0.2.0 → webwidgets-1.0.0}/tests/utility/__init__.py +0 -0
  42. {webwidgets-0.2.0 → webwidgets-1.0.0}/tests/utility/test_sanitizing.py +0 -0
  43. {webwidgets-0.2.0 → webwidgets-1.0.0}/webwidgets/compilation/__init__.py +0 -0
  44. {webwidgets-0.2.0 → webwidgets-1.0.0}/webwidgets/utility/sanitizing.py +0 -0
  45. {webwidgets-0.2.0 → webwidgets-1.0.0}/webwidgets/utility/validation.py +0 -0
@@ -0,0 +1,2 @@
1
+ __pycache__
2
+ ignore
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: webwidgets
3
+ Version: 1.0.0
4
+ Summary: A Python package for designing web UIs.
5
+ Project-URL: Source code, https://github.com/mlaasri/WebWidgets
6
+ Author: mlaasri
7
+ License-File: LICENSE
8
+ Keywords: design,webui
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+
14
+ # WebWidgets
15
+
16
+ ![CI Status](https://img.shields.io/github/actions/workflow/status/mlaasri/WebWidgets/ci-full.yml?branch=main)
17
+
18
+ A Python package for creating web UIs
19
+
20
+ ## Installation
21
+
22
+ You can install **WebWidgets** with `pip`. To install the latest stable version, run:
23
+
24
+ ```bash
25
+ pip install webwidgets
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ **WebWidgets** allows you to create custom widgets and build websites with them. For example:
31
+
32
+ ```python
33
+ import webwidgets as ww
34
+ from webwidgets.compilation.html import HTMLNode, RawText
35
+
36
+ # A <div> element
37
+ class Div(HTMLNode):
38
+ pass
39
+
40
+ # A simple text widget
41
+ class Text(ww.Widget):
42
+ def build(self):
43
+ return Div([RawText("Hello, World!")])
44
+
45
+ # A website with one page containing a Text widget
46
+ page = ww.Page([Text()])
47
+ website = ww.Website([page])
48
+
49
+ # Compile the website into HTML code
50
+ compiled = website.compile()
51
+ print(compiled.html_content[0])
52
+ ```
53
+
54
+ Prints the following result:
55
+
56
+ ```console
57
+ <!DOCTYPE html>
58
+ <html>
59
+ <head></head>
60
+ <body>
61
+ <div>
62
+ Hello, World!
63
+ </div>
64
+ </body>
65
+ </html>
66
+ ```
@@ -0,0 +1,53 @@
1
+ # WebWidgets
2
+
3
+ ![CI Status](https://img.shields.io/github/actions/workflow/status/mlaasri/WebWidgets/ci-full.yml?branch=main)
4
+
5
+ A Python package for creating web UIs
6
+
7
+ ## Installation
8
+
9
+ You can install **WebWidgets** with `pip`. To install the latest stable version, run:
10
+
11
+ ```bash
12
+ pip install webwidgets
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ **WebWidgets** allows you to create custom widgets and build websites with them. For example:
18
+
19
+ ```python
20
+ import webwidgets as ww
21
+ from webwidgets.compilation.html import HTMLNode, RawText
22
+
23
+ # A <div> element
24
+ class Div(HTMLNode):
25
+ pass
26
+
27
+ # A simple text widget
28
+ class Text(ww.Widget):
29
+ def build(self):
30
+ return Div([RawText("Hello, World!")])
31
+
32
+ # A website with one page containing a Text widget
33
+ page = ww.Page([Text()])
34
+ website = ww.Website([page])
35
+
36
+ # Compile the website into HTML code
37
+ compiled = website.compile()
38
+ print(compiled.html_content[0])
39
+ ```
40
+
41
+ Prints the following result:
42
+
43
+ ```console
44
+ <!DOCTYPE html>
45
+ <html>
46
+ <head></head>
47
+ <body>
48
+ <div>
49
+ Hello, World!
50
+ </div>
51
+ </body>
52
+ </html>
53
+ ```
@@ -11,12 +11,38 @@
11
11
  # =======================================================================
12
12
 
13
13
  import pytest
14
+ from typing import Any, Dict, List
14
15
  from webwidgets.compilation.html.html_node import HTMLNode
15
16
  from webwidgets.compilation.html.html_tags import TextNode
16
- from webwidgets.compilation.css.css import compile_css, CompiledCSS, apply_css
17
+ from webwidgets.compilation.css.css import compile_css, CSSRule, CompiledCSS, \
18
+ apply_css, default_rule_namer
17
19
 
18
20
 
19
21
  class TestCompileCSS:
22
+ @staticmethod
23
+ def _serialize_rules(rules: List[CSSRule]) -> List[Dict[str, Any]]:
24
+ """Utility function to convert a list of :py:class:`CSSRule` objects
25
+ into a dictionary that can be used in testing.
26
+
27
+ :param rules: List of :py:class:`CSSRule` objects.
28
+ :type rules: List[CSSRule]
29
+ :return: List of the member variables of each :py:class:`CSSRule`.
30
+ :rtype: Dict[int, Any]
31
+ """
32
+ return [vars(rule) for rule in rules]
33
+
34
+ @staticmethod
35
+ def _serialize_mapping(mapping: Dict[int, List[CSSRule]]) -> Dict[int, List[str]]:
36
+ """Utility function to convert a :py:attr:`CompiledCSS.mapping` object
37
+ into a dictionary that can be used in testing.
38
+
39
+ :param mapping: :py:attr:`CompiledCSS.mapping` object.
40
+ :type mapping: Dict[int, List[CSSRule]]
41
+ :return: Dictionary mapping each node ID to the name of the rules that
42
+ achieve the same style.
43
+ """
44
+ return {i: [r.name for r in rules] for i, rules in mapping.items()}
45
+
20
46
  def test_argument_type(self):
21
47
  """Compares compilation when given a node object versus a list of
22
48
  nodes.
@@ -30,10 +56,10 @@ class TestCompileCSS:
30
56
  )
31
57
 
32
58
  # Define expected compilation results
33
- expected_rules = {
34
- 'r0': {'a': '5'},
35
- 'r1': {'b': '4'}
36
- }
59
+ expected_rules = [
60
+ {"name": "r0", "declarations": {"a": "5"}},
61
+ {"name": "r1", "declarations": {"b": "4"}}
62
+ ]
37
63
  expected_mapping = {
38
64
  id(tree): ['r0', 'r1'],
39
65
  id(tree.children[0]): ['r0']
@@ -45,8 +71,10 @@ class TestCompileCSS:
45
71
  # Check results of compilation
46
72
  assert compiled_css.trees == [tree]
47
73
  assert [id(t) for t in compiled_css.trees] == [id(tree)]
48
- assert compiled_css.rules == expected_rules
49
- assert compiled_css.mapping == expected_mapping
74
+ assert TestCompileCSS._serialize_rules(
75
+ compiled_css.rules) == expected_rules
76
+ assert TestCompileCSS._serialize_mapping(
77
+ compiled_css.mapping) == expected_mapping
50
78
 
51
79
  # Compile tree as list of one node
52
80
  compiled_css2 = compile_css([tree])
@@ -54,8 +82,10 @@ class TestCompileCSS:
54
82
  # Check results of compilation again (should be unchanged)
55
83
  assert compiled_css2.trees == [tree]
56
84
  assert [id(t) for t in compiled_css2.trees] == [id(tree)]
57
- assert compiled_css2.rules == expected_rules
58
- assert compiled_css2.mapping == expected_mapping
85
+ assert TestCompileCSS._serialize_rules(
86
+ compiled_css2.rules) == expected_rules
87
+ assert TestCompileCSS._serialize_mapping(
88
+ compiled_css2.mapping) == expected_mapping
59
89
 
60
90
  def test_basic_compilation(self):
61
91
  # Create some HTML nodes with different styles
@@ -72,17 +102,19 @@ class TestCompileCSS:
72
102
  id(node1), id(node2), id(node3)]
73
103
 
74
104
  # Check that the rules are correctly generated
75
- expected_rules = {
76
- 'r0': {'color': 'blue'},
77
- 'r1': {'margin': '0'},
78
- 'r2': {'padding': '0'}
79
- }
80
- assert compiled_css.rules == expected_rules
105
+ expected_rules = [
106
+ {"name": "r0", "declarations": {"color": "blue"}},
107
+ {"name": "r1", "declarations": {"margin": "0"}},
108
+ {"name": "r2", "declarations": {"padding": "0"}}
109
+ ]
110
+ assert TestCompileCSS._serialize_rules(
111
+ compiled_css.rules) == expected_rules
81
112
 
82
113
  # Check that the mapping is correctly generated
83
114
  expected_mapping = {id(node1): ['r1', 'r2'], id(
84
115
  node2): ['r0', 'r1'], id(node3): ['r1', 'r2']}
85
- assert compiled_css.mapping == expected_mapping
116
+ assert TestCompileCSS._serialize_mapping(
117
+ compiled_css.mapping) == expected_mapping
86
118
 
87
119
  def test_nested_compilation_one_tree(self):
88
120
  # Create some nested HTML nodes
@@ -104,13 +136,14 @@ class TestCompileCSS:
104
136
  assert [id(t) for t in compiled_css.trees] == [id(tree)]
105
137
 
106
138
  # Check that the rules are correctly generated
107
- expected_rules = {
108
- 'r0': {'color': 'blue'},
109
- 'r1': {'margin': '0'},
110
- 'r2': {'margin': '5'},
111
- 'r3': {'padding': '0'}
112
- }
113
- assert compiled_css.rules == expected_rules
139
+ expected_rules = [
140
+ {"name": "r0", "declarations": {"color": "blue"}},
141
+ {"name": "r1", "declarations": {"margin": "0"}},
142
+ {"name": "r2", "declarations": {"margin": "5"}},
143
+ {"name": "r3", "declarations": {"padding": "0"}}
144
+ ]
145
+ assert TestCompileCSS._serialize_rules(
146
+ compiled_css.rules) == expected_rules
114
147
 
115
148
  # Check that the mapping is correctly generated
116
149
  expected_mapping = {
@@ -120,7 +153,8 @@ class TestCompileCSS:
120
153
  id(tree.children[0].children[0]): [],
121
154
  id(tree.children[1].children[0]): []
122
155
  }
123
- assert compiled_css.mapping == expected_mapping
156
+ assert TestCompileCSS._serialize_mapping(
157
+ compiled_css.mapping) == expected_mapping
124
158
 
125
159
  def test_nested_compilation_two_trees(self):
126
160
  # Create 2 trees
@@ -146,13 +180,14 @@ class TestCompileCSS:
146
180
  id(tree1), id(tree2)]
147
181
 
148
182
  # Check that the rules are correctly generated
149
- expected_rules = {
150
- 'r0': {'color': 'red'},
151
- 'r1': {'margin': '10'},
152
- 'r2': {'margin': '5'},
153
- 'r3': {'padding': '0'}
154
- }
155
- assert compiled_css.rules == expected_rules
183
+ expected_rules = [
184
+ {"name": "r0", "declarations": {"color": "red"}},
185
+ {"name": "r1", "declarations": {"margin": "10"}},
186
+ {"name": "r2", "declarations": {"margin": "5"}},
187
+ {"name": "r3", "declarations": {"padding": "0"}}
188
+ ]
189
+ assert TestCompileCSS._serialize_rules(
190
+ compiled_css.rules) == expected_rules
156
191
 
157
192
  # Check that the mapping is correctly generated
158
193
  expected_mapping = {
@@ -161,7 +196,8 @@ class TestCompileCSS:
161
196
  id(tree2): ['r2', 'r3'],
162
197
  id(tree2.children[0]): ['r1']
163
198
  }
164
- assert compiled_css.mapping == expected_mapping
199
+ assert TestCompileCSS._serialize_mapping(
200
+ compiled_css.mapping) == expected_mapping
165
201
 
166
202
  def test_rules_numbered_in_order(self):
167
203
  """Test that rules are numbered in lexicographical order"""
@@ -174,14 +210,15 @@ class TestCompileCSS:
174
210
  ]
175
211
  )
176
212
  compiled_css = compile_css(tree)
177
- expected_rules = {
178
- 'r0': {'a': '10'},
179
- 'r1': {'a': '5'},
180
- 'r2': {'b': '10'},
181
- 'r3': {'b': '4'},
182
- 'r4': {'c': '5'}
183
- }
184
- assert compiled_css.rules == expected_rules
213
+ expected_rules = [
214
+ {"name": "r0", "declarations": {"a": "10"}},
215
+ {"name": "r1", "declarations": {"a": "5"}},
216
+ {"name": "r2", "declarations": {"b": "10"}},
217
+ {"name": "r3", "declarations": {"b": "4"}},
218
+ {"name": "r4", "declarations": {"c": "5"}},
219
+ ]
220
+ assert TestCompileCSS._serialize_rules(
221
+ compiled_css.rules) == expected_rules
185
222
 
186
223
  def test_duplicate_node(self):
187
224
  """Test that adding the same node twice does not impact compilation"""
@@ -193,11 +230,11 @@ class TestCompileCSS:
193
230
  HTMLNode(style={"b": "10"}),
194
231
  ]
195
232
  )
196
- expected_rules = {
197
- 'r0': {'a': '5'},
198
- 'r1': {'b': '10'},
199
- 'r2': {'b': '4'}
200
- }
233
+ expected_rules = [
234
+ {"name": "r0", "declarations": {"a": "5"}},
235
+ {"name": "r1", "declarations": {"b": "10"}},
236
+ {"name": "r2", "declarations": {"b": "4"}}
237
+ ]
201
238
  expected_mapping = {
202
239
  id(tree): ['r0', 'r2'],
203
240
  id(tree.children[0]): ['r0'],
@@ -206,8 +243,10 @@ class TestCompileCSS:
206
243
  compiled_css = compile_css([tree])
207
244
  assert compiled_css.trees == [tree]
208
245
  assert [id(t) for t in compiled_css.trees] == [id(tree)]
209
- assert compiled_css.rules == expected_rules
210
- assert compiled_css.mapping == expected_mapping
246
+ assert TestCompileCSS._serialize_rules(
247
+ compiled_css.rules) == expected_rules
248
+ assert TestCompileCSS._serialize_mapping(
249
+ compiled_css.mapping) == expected_mapping
211
250
 
212
251
  # Compiling the tree and one of its children, which should already be
213
252
  # included recursively from the tree itself and should not affect the
@@ -216,25 +255,41 @@ class TestCompileCSS:
216
255
  assert compiled_css2.trees == [tree, tree.children[0]]
217
256
  assert [id(t) for t in compiled_css2.trees] == [
218
257
  id(tree), id(tree.children[0])]
219
- assert compiled_css2.rules == expected_rules
220
- assert compiled_css2.mapping == expected_mapping
258
+ assert TestCompileCSS._serialize_rules(
259
+ compiled_css2.rules) == expected_rules
260
+ assert TestCompileCSS._serialize_mapping(
261
+ compiled_css2.mapping) == expected_mapping
262
+
263
+ @pytest.mark.parametrize("rule_namer, names", [
264
+ (lambda _, i: f"rule{i}", ["rule0", "rule1", "rule2"]),
265
+ (lambda _, i: f"rule-{i + 1}", ["rule-1", "rule-2", "rule-3"]),
266
+ (lambda r, i: f"{list(r[i].declarations.items())[0][0]}{i}", [
267
+ "az0", "bz1", "bz2"]),
268
+ (lambda r, i: f"{list(r[i].declarations.items())[0][0][0]}{i}", [
269
+ "a0", "b1", "b2"]),
270
+ (lambda r, i: f"r{list(r[i].declarations.items())[0][1]}-{i}", [
271
+ "r10-1", "r4-2", "r5-0"]),
272
+ ])
273
+ def test_custom_rule_names(self, rule_namer, names):
274
+ tree = HTMLNode(
275
+ style={"az": "5", "bz": "4"},
276
+ children=[
277
+ HTMLNode(style={"az": "5"}),
278
+ HTMLNode(style={"bz": "10"}),
279
+ ]
280
+ )
281
+ compiled_css = compile_css(tree, rule_namer=rule_namer)
282
+ assert [r.name for r in compiled_css.rules] == names
221
283
 
222
284
 
223
285
  class TestCompiledCSS:
224
286
  def test_export_custom_compiled_css(self):
225
- rules = {
226
- "r0": {
227
- "margin": "0",
228
- "padding": "0"
229
- },
230
- "r1": {
231
- "color": "blue"
232
- },
233
- "r2": {
234
- "background-color": "white",
235
- "font-size": "16px"
236
- }
237
- }
287
+ rules = [
288
+ CSSRule(name="r0", declarations={"margin": "0", "padding": "0"}),
289
+ CSSRule(name="r1", declarations={"color": "blue"}),
290
+ CSSRule(name="r2", declarations={
291
+ "background-color": "white", "font-size": "16px"})
292
+ ]
238
293
  compiled_css = CompiledCSS(trees=None,
239
294
  rules=rules,
240
295
  mapping=None)
@@ -364,12 +419,12 @@ class TestApplyCSS:
364
419
 
365
420
  # Compiling and applying CSS to the tree
366
421
  compiled_css = compile_css(tree)
367
- assert compiled_css.rules == {
368
- "r0": {"color": "blue"},
369
- "r1": {"color": "green"},
370
- "r2": {"margin": "0"},
371
- "r3": {"padding": "0"}
372
- }
422
+ assert TestCompileCSS._serialize_rules(compiled_css.rules) == [
423
+ {"name": "r0", "declarations": {"color": "blue"}},
424
+ {"name": "r1", "declarations": {"color": "green"}},
425
+ {"name": "r2", "declarations": {"margin": "0"}},
426
+ {"name": "r3", "declarations": {"padding": "0"}}
427
+ ]
373
428
  apply_css(compiled_css, tree)
374
429
 
375
430
  # Checking the tree's new classes
@@ -398,7 +453,7 @@ class TestApplyCSS:
398
453
  )
399
454
  html_before = tree.to_html()
400
455
  compiled_css = compile_css(tree)
401
- assert compiled_css.rules == {}
456
+ assert compiled_css.rules == []
402
457
  apply_css(compiled_css, tree)
403
458
  html_after = tree.to_html()
404
459
 
@@ -475,3 +530,23 @@ class TestApplyCSS:
475
530
  apply_css(compiled_css, tree)
476
531
  assert "class" not in tree.attributes
477
532
  assert tree.to_html() == '<htmlnode></htmlnode>'
533
+
534
+
535
+ class TestDefaultRuleNamer:
536
+ def test_default_rule_namer(self):
537
+ rules = [CSSRule(None, {"color": "red"}),
538
+ CSSRule(None, {"margin": "0"})]
539
+ for i, rule in enumerate(rules):
540
+ rule.name = default_rule_namer(rules=rules, index=i)
541
+ assert rules[0].name == "r0"
542
+ assert rules[1].name == "r1"
543
+
544
+ def test_default_rule_namer_override(self):
545
+ rules = [CSSRule("first", {"color": "red"}),
546
+ CSSRule("second", {"margin": "0"})]
547
+ assert rules[0].name == "first"
548
+ assert rules[1].name == "second"
549
+ for i, rule in enumerate(rules):
550
+ rule.name = default_rule_namer(rules=rules, index=i)
551
+ assert rules[0].name == "r0"
552
+ assert rules[1].name == "r1"
@@ -12,7 +12,7 @@
12
12
 
13
13
  import pytest
14
14
  from webwidgets.compilation.html.html_node import HTMLNode, no_start_tag, \
15
- no_end_tag, one_line, RawText
15
+ no_end_tag, one_line, RawText, RootNode
16
16
 
17
17
 
18
18
  class TestHTMLNode:
@@ -63,9 +63,14 @@ class TestHTMLNode:
63
63
 
64
64
  def test_attributes(self):
65
65
  node = HTMLNode(attributes={'id': 'test-id', 'class': 'test-class'})
66
- assert node.start_tag == '<htmlnode id="test-id" class="test-class">'
66
+ assert node.start_tag == '<htmlnode class="test-class" id="test-id">'
67
67
  assert node.end_tag == '</htmlnode>'
68
- assert node.to_html() == '<htmlnode id="test-id" class="test-class"></htmlnode>'
68
+ assert node.to_html() == '<htmlnode class="test-class" id="test-id"></htmlnode>'
69
+
70
+ def test_attributes_order(self):
71
+ node = HTMLNode(attributes={'d': '0', 'a': '1', 'c': '2', 'b': '3'})
72
+ assert node._render_attributes() == 'a="1" b="3" c="2" d="0"'
73
+ assert node.to_html() == '<htmlnode a="1" b="3" c="2" d="0"></htmlnode>'
69
74
 
70
75
  def test_no_start_tag(self):
71
76
  node = TestHTMLNode.NoStartNode()
@@ -230,7 +235,7 @@ class TestHTMLNode:
230
235
  assert node.to_html() == expected_html
231
236
 
232
237
  @pytest.mark.parametrize("indent_level", [0, 1, 2])
233
- @pytest.mark.parametrize("indent_size", [3, 4, 8])
238
+ @pytest.mark.parametrize("indent_size", [2, 3, 4, 8])
234
239
  def test_indentation(self, indent_level: int, indent_size: int):
235
240
  """Test the to_html method with different indentation parameters."""
236
241
 
@@ -270,6 +275,28 @@ class TestHTMLNode:
270
275
  indent_size=indent_size, indent_level=indent_level)
271
276
  assert actual_html == expected_html
272
277
 
278
+ @pytest.mark.parametrize("indent_level", [-3, -2, -1])
279
+ def test_negative_indent_level(self, indent_level: int):
280
+ node = HTMLNode(children=[
281
+ RawText('child1'),
282
+ RawText('child2'),
283
+ HTMLNode(children=[
284
+ RawText('grandchild1'),
285
+ RawText('grandchild2')
286
+ ])
287
+ ])
288
+ expected_html = "\n".join([
289
+ "<htmlnode>",
290
+ "child1",
291
+ "child2",
292
+ "<htmlnode>",
293
+ f"{' ' if indent_level == -1 else ''}grandchild1",
294
+ f"{' ' if indent_level == -1 else ''}grandchild2",
295
+ "</htmlnode>",
296
+ "</htmlnode>"
297
+ ])
298
+ assert node.to_html(indent_level=indent_level) == expected_html
299
+
273
300
  def test_collapse_empty(self):
274
301
  node = HTMLNode(children=[
275
302
  TestHTMLNode.CustomNode(),
@@ -441,3 +468,16 @@ class TestHTMLNode:
441
468
  copied_node.children.append(child)
442
469
  assert len(node.children) == 1
443
470
  assert id(node.children[0]) == id(child)
471
+
472
+ def test_empty_root_node(self):
473
+ node = RootNode()
474
+ assert node.to_html() == ""
475
+
476
+ @pytest.mark.parametrize("n", [1, 2, 3])
477
+ def test_root_node_with_children(self, n):
478
+ node = RootNode(
479
+ children=[HTMLNode()] * n
480
+ )
481
+ expected_html = "\n".join(["<htmlnode></htmlnode>"] * n)
482
+ print(expected_html)
483
+ assert node.to_html() == expected_html
@@ -21,7 +21,21 @@ class TestHTMLTags:
21
21
  def test_text_node_with_attributes(self):
22
22
  text_node = TextNode("Hello, World!",
23
23
  attributes={"class": "my-class", "id": "my-id"})
24
- assert text_node.to_html() in [
25
- '<textnode class="my-class" id="my-id">Hello, World!</textnode>',
26
- '<textnode id="my-id" class="my-class">Hello, World!</textnode>',
27
- ]
24
+ assert text_node.to_html() == \
25
+ '<textnode class="my-class" id="my-id">Hello, World!</textnode>'
26
+
27
+ def test_body(self):
28
+ body = Body()
29
+ assert body.to_html() == "<body></body>"
30
+
31
+ def test_doctype(self):
32
+ doctype = Doctype()
33
+ assert doctype.to_html() == "<!DOCTYPE html>"
34
+
35
+ def test_head(self):
36
+ head = Head()
37
+ assert head.to_html() == "<head></head>"
38
+
39
+ def test_html(self):
40
+ html = Html()
41
+ assert html.to_html() == "<html></html>"
@@ -0,0 +1,29 @@
1
+ # =======================================================================
2
+ #
3
+ # This file is part of WebWidgets, a Python package for designing web
4
+ # UIs.
5
+ #
6
+ # You should have received a copy of the MIT License along with
7
+ # WebWidgets. If not, see <https://opensource.org/license/mit>.
8
+ #
9
+ # Copyright(C) 2025, mlaasri
10
+ #
11
+ # =======================================================================
12
+
13
+ import pytest
14
+ from webwidgets.utility.indentation import get_indentation
15
+
16
+
17
+ class TestIndentation:
18
+ @pytest.mark.parametrize("indent_level", [0, 1, 2])
19
+ @pytest.mark.parametrize("indent_size", [3, 4, 8])
20
+ def test_get_indentation(self, indent_level: int, indent_size: int):
21
+ """Tests get_indentation with different indentation levels and sizes."""
22
+ expected_indentation = ' ' * indent_size * indent_level
23
+ assert get_indentation(
24
+ indent_level, indent_size) == expected_indentation
25
+
26
+ @pytest.mark.parametrize("indent_level", [-2, -1, 0])
27
+ @pytest.mark.parametrize("indent_size", [3, 4, 8])
28
+ def test_get_indentation_for_negative_levels(self, indent_level, indent_size):
29
+ assert get_indentation(indent_level, indent_size) == ''