Skip to content

Commit 5cff1da

Browse files
committed
Initial implementation of pydantic driven QTI XML generation.
1 parent 0915c49 commit 5cff1da

32 files changed

Lines changed: 3836 additions & 1 deletion

contentcuration/contentcuration/tests/utils/qti/__init__.py

Whitespace-only changes.

contentcuration/contentcuration/tests/utils/qti/test_assessment_items.py

Lines changed: 504 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
import unittest
2+
3+
from contentcuration.utils.assessment.qti.fields import validate_data_uri
4+
from contentcuration.utils.assessment.qti.fields import validate_local_href_path
5+
from contentcuration.utils.assessment.qti.fields import validate_local_src_path
6+
from contentcuration.utils.assessment.qti.fields import validate_local_srcset
7+
8+
9+
class TestValidateDataUri(unittest.TestCase):
10+
def test_valid_data_uris(self):
11+
valid_uris = [
12+
"data:text/plain;base64,SGVsbG8=",
13+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
14+
"data:text/plain,Hello%20World",
15+
"data:,Hello",
16+
"data:text/html,<h1>Hello</h1>",
17+
'data:application/json,{"key":"value"}',
18+
"data:text/css,body{color:red}",
19+
"data:image/svg+xml,<svg></svg>",
20+
"data:text/plain;charset=utf-8,Hello",
21+
"data:text/plain;charset=utf-8;base64,SGVsbG8=",
22+
]
23+
24+
for uri in valid_uris:
25+
with self.subTest(uri=uri):
26+
result = validate_data_uri(uri)
27+
self.assertEqual(result, uri, f"Should return the same URI: {uri}")
28+
29+
def test_invalid_data_uris(self):
30+
"""Test invalid data URI formats"""
31+
invalid_uris = [
32+
"not-a-data-uri",
33+
"data:",
34+
"data",
35+
"http://example.com",
36+
"https://example.com/image.png",
37+
"ftp://example.com/file.txt",
38+
"file:///path/to/file",
39+
"",
40+
"data:text/plain",
41+
"ata:text/plain,Hello",
42+
]
43+
44+
for uri in invalid_uris:
45+
with self.subTest(uri=uri):
46+
with self.assertRaises(ValueError) as cm:
47+
validate_data_uri(uri)
48+
self.assertIn("Invalid data URI format", str(cm.exception))
49+
50+
51+
class TestValidateLocalHrefPath(unittest.TestCase):
52+
def test_valid_relative_paths(self):
53+
"""Test valid relative paths"""
54+
valid_paths = [
55+
"relative/path.jpg",
56+
"../path.jpg",
57+
"./file.png",
58+
"file.txt",
59+
"images/photo.jpg",
60+
"docs/readme.md",
61+
"assets/style.css",
62+
"#fragment",
63+
"?query=value",
64+
"#fragment?query=value",
65+
"path/to/file.html#section",
66+
"subdir/../file.txt",
67+
]
68+
69+
for path in valid_paths:
70+
with self.subTest(path=path):
71+
result = validate_local_href_path(path)
72+
self.assertEqual(result, path, f"Should return the same path: {path}")
73+
74+
def test_valid_data_uris_in_href(self):
75+
data_uris = [
76+
"data:text/plain,Hello",
77+
"data:image/png;base64,iVBORw0KGgo=",
78+
]
79+
80+
for uri in data_uris:
81+
with self.subTest(uri=uri):
82+
result = validate_local_href_path(uri)
83+
self.assertEqual(result, uri)
84+
85+
def test_invalid_absolute_urls(self):
86+
absolute_urls = [
87+
"http://example.com",
88+
"https://example.com/path",
89+
"ftp://example.com/file",
90+
"mailto:test@example.com",
91+
"tel:+1234567890",
92+
"//example.com/path",
93+
"/absolute/path",
94+
"/",
95+
]
96+
97+
for url in absolute_urls:
98+
with self.subTest(url=url):
99+
with self.assertRaises(ValueError) as cm:
100+
validate_local_href_path(url)
101+
self.assertIn("Absolute URLs not allowed", str(cm.exception))
102+
103+
def test_invalid_data_uris_in_href(self):
104+
"""Test that invalid data URIs are rejected"""
105+
with self.assertRaises(ValueError) as cm:
106+
validate_local_href_path("data:invalid")
107+
self.assertIn("Invalid data URI format", str(cm.exception))
108+
109+
110+
class TestValidateLocalSrcPath(unittest.TestCase):
111+
def test_valid_src_paths(self):
112+
"""Test valid src paths (must have actual file paths)"""
113+
valid_paths = [
114+
"relative/path.jpg",
115+
"../path.jpg",
116+
"./file.png",
117+
"file.txt",
118+
"images/photo.jpg",
119+
"subdir/../file.txt",
120+
]
121+
122+
for path in valid_paths:
123+
with self.subTest(path=path):
124+
result = validate_local_src_path(path)
125+
self.assertEqual(result, path)
126+
127+
def test_valid_data_uris_in_src(self):
128+
data_uris = [
129+
"data:text/plain,Hello",
130+
"data:image/png;base64,iVBORw0KGgo=",
131+
]
132+
133+
for uri in data_uris:
134+
with self.subTest(uri=uri):
135+
result = validate_local_src_path(uri)
136+
self.assertEqual(result, uri)
137+
138+
def test_invalid_empty_paths(self):
139+
"""Test rejection of empty paths and fragment-only"""
140+
invalid_paths = ["#fragment", "?query=value", "#fragment?query=value"]
141+
142+
for path in invalid_paths:
143+
with self.subTest(path=path):
144+
with self.assertRaises(ValueError) as cm:
145+
validate_local_src_path(path)
146+
self.assertIn("Invalid local src path", str(cm.exception))
147+
148+
def test_absolute_urls_rejected(self):
149+
"""Test that absolute URLs are still rejected"""
150+
with self.assertRaises(ValueError) as cm:
151+
validate_local_src_path("http://example.com/image.jpg")
152+
self.assertIn("Absolute URLs not allowed", str(cm.exception))
153+
154+
155+
class TestValidateLocalSrcset(unittest.TestCase):
156+
def test_empty_srcset(self):
157+
empty_values = ["", " ", "\t", "\n"]
158+
159+
for value in empty_values:
160+
with self.subTest(value=repr(value)):
161+
result = validate_local_srcset(value)
162+
self.assertEqual(result, value)
163+
164+
def test_single_image_srcset(self):
165+
valid_srcsets = [
166+
"image.jpg 2x",
167+
"image.jpg 1.5x",
168+
"image.jpg 100w",
169+
"image.jpg 50h",
170+
"image.jpg 0.5x",
171+
"path/to/image.png 2x",
172+
"../images/photo.jpg 1x",
173+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg== 2x",
174+
]
175+
176+
for srcset in valid_srcsets:
177+
with self.subTest(srcset=srcset):
178+
result = validate_local_srcset(srcset)
179+
self.assertEqual(result, srcset)
180+
181+
def test_data_uri_in_srcset(self):
182+
valid_data_srcsets = [
183+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg== 1x",
184+
"data:text/plain,Hello%20World 2x",
185+
"data:image/svg+xml,<svg><circle r='10'/></svg> 1.5x",
186+
'data:application/json,{"key":"value"} 100w',
187+
]
188+
189+
for srcset in valid_data_srcsets:
190+
with self.subTest(srcset=srcset):
191+
result = validate_local_srcset(srcset)
192+
self.assertEqual(result, srcset)
193+
194+
def test_multiple_images_srcset(self):
195+
valid_srcsets = [
196+
"small.jpg 1x, large.jpg 2x",
197+
"img-320.jpg 320w, img-640.jpg 640w, img-1280.jpg 1280w",
198+
"portrait.jpg 480h, landscape.jpg 960h",
199+
"image1.jpg 1x, image2.jpg 1.5x, image3.jpg 2x",
200+
"a.jpg 1x,b.jpg 2x", # minimal spacing
201+
]
202+
203+
for srcset in valid_srcsets:
204+
with self.subTest(srcset=srcset):
205+
result = validate_local_srcset(srcset)
206+
self.assertEqual(result, srcset)
207+
208+
def test_mixed_data_uri_and_regular_paths(self):
209+
valid_mixed_srcsets = [
210+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg== 1x, large.jpg 2x",
211+
"small.jpg 1x, data:image/svg+xml,<svg><rect width='100' height='100'/></svg> 2x",
212+
"icon.png 1x, data:text/plain,fallback 2x, large.png 3x",
213+
]
214+
215+
for srcset in valid_mixed_srcsets:
216+
with self.subTest(srcset=srcset):
217+
result = validate_local_srcset(srcset)
218+
self.assertEqual(result, srcset)
219+
220+
def test_multiple_data_uris_in_srcset(self):
221+
valid_multi_data_srcsets = [
222+
"data:image/png;base64,ABC123 1x, data:image/png;base64,DEF456 2x",
223+
"data:text/plain,Small,Image 1x, data:text/plain,Large,Image 2x",
224+
"data:image/svg+xml,<svg><circle r='5'/></svg> 1x, data:image/svg+xml,<svg><circle r='10'/></svg> 2x, data:image/svg+xml,<svg><circle r='15'/></svg> 3x", # noqa: E501
225+
'data:application/json,{"size":"small"} 100w, data:application/json,{"size":"large"} 200w',
226+
]
227+
228+
for srcset in valid_multi_data_srcsets:
229+
with self.subTest(srcset=srcset):
230+
result = validate_local_srcset(srcset)
231+
self.assertEqual(result, srcset)
232+
233+
def test_complex_mixed_srcsets(self):
234+
complex_srcsets = [
235+
"thumb.jpg 1x, data:image/png;base64,MID123 1.5x, data:image/svg+xml,<svg><rect/></svg> 2x, large.jpg 3x",
236+
"data:text/plain,Icon,1 50w, regular-100.jpg 100w, data:text/plain,Icon,2 150w, regular-200.jpg 200w",
237+
]
238+
239+
for srcset in complex_srcsets:
240+
with self.subTest(srcset=srcset):
241+
result = validate_local_srcset(srcset)
242+
self.assertEqual(result, srcset)
243+
244+
def test_invalid_descriptors(self):
245+
"""Test rejection of invalid descriptors"""
246+
invalid_srcsets = [
247+
"image.jpg 2", # missing unit
248+
"image.jpg x", # missing number
249+
"image.jpg 2z", # invalid unit
250+
"image.jpg 2.x", # malformed number
251+
"image.jpg .x", # malformed number
252+
"image.jpg 2xx", # double unit
253+
"image.jpg -2x", # negative number
254+
"image.jpg 2 x", # space in descriptor
255+
]
256+
257+
for srcset in invalid_srcsets:
258+
with self.subTest(srcset=srcset):
259+
with self.assertRaises(ValueError):
260+
validate_local_srcset(srcset)
261+
262+
def test_invalid_urls_in_srcset(self):
263+
invalid_srcsets = [
264+
"http://example.com/image.jpg 2x",
265+
"https://cdn.example.com/img.png 1x, local.jpg 2x",
266+
"/absolute/path.jpg 1x",
267+
]
268+
269+
for srcset in invalid_srcsets:
270+
with self.subTest(srcset=srcset):
271+
with self.assertRaises(ValueError):
272+
validate_local_srcset(srcset)
273+
274+
def test_empty_srcset_entries(self):
275+
invalid_srcsets = [
276+
"image.jpg 2x, ,other.jpg 1x",
277+
", image.jpg 2x",
278+
"image.jpg 2x,",
279+
]
280+
281+
for srcset in invalid_srcsets:
282+
with self.subTest(srcset=srcset):
283+
with self.assertRaises(ValueError):
284+
validate_local_srcset(srcset)
285+
286+
def test_missing_path_in_srcset(self):
287+
invalid_srcsets = [
288+
"#fragment 2x",
289+
"?query=value 1x",
290+
]
291+
292+
for srcset in invalid_srcsets:
293+
with self.subTest(srcset=srcset):
294+
with self.assertRaises(ValueError):
295+
validate_local_srcset(srcset)
296+
297+
298+
class TestEdgeCases(unittest.TestCase):
299+
def test_unicode_paths_href(self):
300+
unicode_paths = ["café/ñ.jpg", "文件/图片.png", "файл.txt"]
301+
302+
for path in unicode_paths:
303+
with self.subTest(path=path):
304+
result = validate_local_href_path(path)
305+
self.assertEqual(result, path)
306+
307+
def test_unicode_paths_src(self):
308+
unicode_paths = ["café/ñ.jpg", "文件/图片.png", "файл.txt"]
309+
310+
for path in unicode_paths:
311+
with self.subTest(path=path):
312+
result = validate_local_src_path(path)
313+
self.assertEqual(result, path)
314+
315+
def test_very_long_paths(self):
316+
long_path = "a/" * 1000 + "file.txt"
317+
318+
# Should handle long paths gracefully
319+
result = validate_local_href_path(long_path)
320+
self.assertEqual(result, long_path)
321+
322+
def test_special_characters_in_data_uri(self):
323+
special_data_uris = [
324+
"data:text/plain,Hello%20World%21",
325+
"data:text/plain,<>&\"'",
326+
'data:application/json,{"key":"value"}',
327+
]
328+
329+
for uri in special_data_uris:
330+
with self.subTest(uri=uri):
331+
result = validate_data_uri(uri)
332+
self.assertEqual(result, uri)

0 commit comments

Comments
 (0)