Skip to main content

tests/test_javascript_data_files.py

1"""
2Tests for the ``javascript`` module.
3"""
5import io
6import pathlib
7import typing
9import pydantic
10import pytest
12from javascript_data_files import (
13 append_to_js_array,
14 append_to_js_object,
15 read_js,
16 read_typed_js,
17 write_js,
21@pytest.fixture
22def js_path(tmp_path: pathlib.Path) -> pathlib.Path:
23 """
24 Return a path to a JavaScript file.
26 This only returns the path and does not create the file.
27 """
28 return tmp_path / "data.js"
31class TestReadJs:
32 """
33 Tests for the ``read_js()`` function.
34 """
36 @pytest.mark.parametrize(
37 "text",
38 [
39 'const redPentagon = {\n "sides": 5,\n "colour": "red"\n};\n',
40 'var redPentagon = {\n "sides": 5,\n "colour": "red"\n};\n',
41 'redPentagon = {\n "sides": 5,\n "colour": "red"\n};\n',
42 'const redPentagon = {\n "sides": 5,\n "colour": "red"\n};',
43 'const redPentagon = {\n "sides": 5,\n "colour": "red"\n}',
44 ],
45 )
46 def test_can_read_file(self, js_path: pathlib.Path, text: str) -> None:
47 """
48 Test values can be read from a file correctly.
50 Test that the parser does not care about:
52 * whitespace
53 * trailing semicolon or not
54 * a var/const prefix
56 """
57 js_path.write_text(text)
59 assert read_js(js_path, varname="redPentagon") == {"sides": 5, "colour": "red"}
61 def test_error_if_path_does_not_exist(self) -> None:
62 """
63 Reading a file which doesn't exist throws a FileNotFoundError.
64 """
65 with pytest.raises(FileNotFoundError):
66 read_js("doesnotexist.js", varname="shape")
68 def test_error_if_path_is_directory(self, tmp_path: pathlib.Path) -> None:
69 """
70 Reading a path which is a directory throws an IsADirectoryError.
71 """
72 assert tmp_path.is_dir()
74 with pytest.raises(IsADirectoryError):
75 read_js(tmp_path, varname="shape")
77 def test_non_json_value_is_error(self, js_path: pathlib.Path) -> None:
78 """
79 Reading a file which doesn't contain a JavaScript "data value"
80 throws a ValueError.
81 """
82 js_path.write_text("const sum = 1 + 1 + 1;")
84 with pytest.raises(ValueError):
85 read_js(js_path, varname="sum")
87 def test_incorrect_varname_is_error(self, js_path: pathlib.Path) -> None:
88 """
89 Reading a file with the wrong variable name throws a ValueError.
90 """
91 js_path.write_text(
92 'const redPentagon = {\n "sides": 5,\n "colour": "red"\n};\n'
93 )
95 with pytest.raises(
96 ValueError, match="Does not start with JavaScript `const` declaration"
97 ):
98 read_js(js_path, varname="blueTriangle")
101class TestReadTypedJs:
102 """
103 Tests for the ``read_typed_js()`` function.
104 """
106 def test_matches_model(self, js_path: pathlib.Path) -> None:
107 """
108 If the data matches the model, it's read correctly.
109 """
110 js_path.write_text(
111 'const redPentagon = {\n "sides": 5,\n "colour": "red"\n};\n'
112 )
114 Shape = typing.TypedDict("Shape", {"sides": int, "colour": str})
116 shape = read_typed_js(js_path, varname="redPentagon", model=Shape)
118 assert shape == {"sides": 5, "colour": "red"}
120 def test_does_not_match_model(self, js_path: pathlib.Path) -> None:
121 """
122 If the data does not match the model, it throws a ValidationError.
123 """
124 js_path.write_text(
125 'const redPentagon = {\n "sides": 5,\n "colour": "red"\n};\n'
126 )
128 Vehicle = typing.TypedDict("Vehicle", {"wheels": int, "colour": str})
130 with pytest.raises(pydantic.ValidationError):
131 read_typed_js(js_path, varname="redPentagon", model=Vehicle)
133 def test_can_read_int(self, js_path: pathlib.Path) -> None:
134 """
135 It can read typed data which is an int.
136 """
137 js_path.write_text("const theAnswer = 42;\n")
139 answer = read_typed_js(js_path, varname="theAnswer", model=int)
140 assert answer == 42
142 def test_can_read_list_int(self, js_path: pathlib.Path) -> None:
143 """
144 It can read typed data which is an int.
145 """
146 js_path.write_text("const diceValues = [1,2,3,4,5,6];\n")
148 answer = read_typed_js(js_path, varname="diceValues", model=list[int])
149 assert answer == [1, 2, 3, 4, 5, 6]
152class TestWriteJs:
153 """
154 Tests for the ``write_js()`` function.
155 """
157 def test_write_file(self, js_path: pathlib.Path) -> None:
158 """
159 Writing to a file stores the correct JavaScript string.
160 """
161 red_pentagon = {"sides": 5, "colour": "red"}
163 write_js(js_path, value=red_pentagon, varname="redPentagon")
165 assert (
166 js_path.read_text()
167 == 'const redPentagon = {\n "sides": 5,\n "colour": "red"\n};\n'
168 )
170 def test_write_to_str(self, tmp_path: pathlib.Path) -> None:
171 """
172 It can write to a path passed as a ``str``.
173 """
174 red_pentagon = {"sides": 5, "colour": "red"}
176 js_path = tmp_path / "shape.js"
178 write_js(p=str(js_path), value=red_pentagon, varname="redPentagon")
180 assert (
181 js_path.read_text()
182 == 'const redPentagon = {\n "sides": 5,\n "colour": "red"\n};\n'
183 )
185 def test_write_to_path(self, tmp_path: pathlib.Path) -> None:
186 """
187 It can write to a path passed as a ``pathlib.Path``.
188 """
189 red_pentagon = {"sides": 5, "colour": "red"}
191 js_path = tmp_path / "shape.js"
193 write_js(js_path, value=red_pentagon, varname="redPentagon")
195 assert (
196 js_path.read_text()
197 == 'const redPentagon = {\n "sides": 5,\n "colour": "red"\n};\n'
198 )
200 def test_write_to_file(self, tmp_path: pathlib.Path) -> None:
201 """
202 It can write to a file.
203 """
204 red_pentagon = {"sides": 5, "colour": "red"}
206 js_path = tmp_path / "shape.js"
208 with open(js_path, "w") as out_file:
209 write_js(out_file, value=red_pentagon, varname="redPentagon")
211 assert (
212 js_path.read_text()
213 == 'const redPentagon = {\n "sides": 5,\n "colour": "red"\n};\n'
214 )
216 def test_write_to_binary_file(self, tmp_path: pathlib.Path) -> None:
217 """
218 It can write to a file opened in binary mode.
219 """
220 red_pentagon = {"sides": 5, "colour": "red"}
222 js_path = tmp_path / "shape.js"
224 with open(js_path, "wb") as out_file:
225 write_js(out_file, value=red_pentagon, varname="redPentagon")
227 assert (
228 js_path.read_text()
229 == 'const redPentagon = {\n "sides": 5,\n "colour": "red"\n};\n'
230 )
232 def test_write_to_string_buffer(self) -> None:
233 """
234 It can write to a string buffer.
235 """
236 red_pentagon = {"sides": 5, "colour": "red"}
238 string_buffer = io.StringIO()
240 write_js(string_buffer, value=red_pentagon, varname="redPentagon")
242 assert (
243 string_buffer.getvalue()
244 == 'const redPentagon = {\n "sides": 5,\n "colour": "red"\n};\n'
245 )
247 def test_write_to_bytes_buffer(self) -> None:
248 """
249 It can write to a binary buffer.
250 """
251 red_pentagon = {"sides": 5, "colour": "red"}
253 bytes_buffer = io.BytesIO()
255 write_js(bytes_buffer, value=red_pentagon, varname="redPentagon")
257 assert (
258 bytes_buffer.getvalue()
259 == b'const redPentagon = {\n "sides": 5,\n "colour": "red"\n};\n'
260 )
262 def test_write_with_sort_keys(self, tmp_path: pathlib.Path) -> None:
263 """
264 If you pass `sort_keys=True`, it sorts the keys in JSON objects.
265 """
266 red_pentagon = {"sides": 5, "colour": "red"}
268 unsorted_path = tmp_path / "unsorted.js"
269 sorted_path = tmp_path / "sorted.js"
271 write_js(
272 unsorted_path,
273 value=red_pentagon,
274 varname="redPentagon",
275 sort_keys=False,
276 )
277 assert (
278 unsorted_path.read_text()
279 == 'const redPentagon = {\n "sides": 5,\n "colour": "red"\n};\n'
280 )
282 write_js(sorted_path, value=red_pentagon, varname="redPentagon", sort_keys=True)
283 assert (
284 sorted_path.read_text()
285 == 'const redPentagon = {\n "colour": "red",\n "sides": 5\n};\n'
286 )
288 @pytest.mark.parametrize(
289 "ensure_ascii, expected_js",
290 [
291 (False, 'const greeting = "“hello world”";\n'),
292 (True, 'const greeting = "\\u201chello world\\u201d";\n'),
293 ],
294 )
295 def test_write_with_ensure_ascii(
296 self, tmp_path: pathlib.Path, ensure_ascii: bool, expected_js: str
297 ) -> None:
298 """
299 You can pass an `ensure_ascii` parameter.
300 """
301 p = tmp_path / "ascii.js"
302 write_js(
303 p, value="“hello world”", varname="greeting", ensure_ascii=ensure_ascii
304 )
305 assert p.read_text() == expected_js
307 def test_fails_if_file_is_read_only(self, tmp_path: pathlib.Path) -> None:
308 """
309 It cannot write to a file open in read-only mode.
310 """
311 red_pentagon = {"sides": 5, "colour": "red"}
313 js_path = tmp_path / "shape.js"
314 js_path.write_text("// empty file")
316 with pytest.raises(io.UnsupportedOperation):
317 with open(js_path, "r") as out_file:
318 write_js(out_file, value=red_pentagon, varname="redPentagon")
320 def test_throws_typeerror_if_cannot_write(self) -> None:
321 """
322 Writing to something that can't be written to throws a ``TypeError``.
323 """
324 red_pentagon = {"sides": 5, "colour": "red"}
326 with pytest.raises(TypeError):
327 write_js(
328 ["this", "is", "not", "a", "file"], # type: ignore
329 value=red_pentagon,
330 varname="redPentagon",
331 )
333 def test_fails_if_cannot_write_file(self) -> None:
334 """
335 Writing to the root folder throws an IsADirectoryError.
336 """
337 red_pentagon = {"sides": 5, "colour": "red"}
339 with pytest.raises(IsADirectoryError):
340 write_js("/", value=red_pentagon, varname="redPentagon")
342 def test_fails_if_target_is_folder(self, tmp_path: pathlib.Path) -> None:
343 """
344 Writing to a folder throws an IsADirectoryError.
345 """
346 assert tmp_path.is_dir()
348 red_pentagon = {"sides": 5, "colour": "red"}
350 with pytest.raises(IsADirectoryError):
351 write_js(tmp_path, value=red_pentagon, varname="redPentagon")
353 def test_creates_parent_directory(self, tmp_path: pathlib.Path) -> None:
354 """
355 If the parent directory of the output path doesn't exist, it is
356 created before the file is written.
357 """
358 js_path = tmp_path / "1/2/3/shape.js"
359 red_pentagon = {"sides": 5, "colour": "red"}
361 write_js(js_path, value=red_pentagon, varname="redPentagon")
363 assert js_path.exists()
364 assert (
365 js_path.read_text()
366 == 'const redPentagon = {\n "sides": 5,\n "colour": "red"\n};\n'
367 )
370class TestAppendToArray:
371 """
372 Tests for the ``append_to_js_array`` function.
373 """
375 @pytest.mark.parametrize(
376 "text",
377 [
378 'const fruit = ["apple", "banana", "coconut"];\n',
379 'const fruit = ["apple","banana", "coconut"];',
380 'const fruit = [\n "apple",\n "banana",\n "coconut"\n];\n',
381 'const fruit = [\n "apple",\n "banana",\n "coconut"\n];',
382 'const fruit = [\n "apple",\n "banana",\n "coconut"\n]',
383 ],
384 )
385 def test_can_append_array_value(self, js_path: pathlib.Path, text: str) -> None:
386 """
387 After you append an item to an array, you can retrieve the
388 updated array.
390 This is true regardless of what the trailing whitespace in the
391 original file looks like.
392 """
393 js_path.write_text(text)
395 append_to_js_array(js_path, value="damson")
396 assert read_js(js_path, varname="fruit") == [
397 "apple",
398 "banana",
399 "coconut",
400 "damson",
401 ]
403 def test_can_mix_types(self, js_path: pathlib.Path) -> None:
404 """
405 Arrays can contain a mixture of different types.
406 """
407 write_js(js_path, value=["apple", "banana", "coconut"], varname="fruit")
408 append_to_js_array(js_path, value=["damson"])
409 assert read_js(js_path, varname="fruit") == [
410 "apple",
411 "banana",
412 "coconut",
413 ["damson"],
414 ]
416 def test_error_if_file_doesnt_look_like_array(self, js_path: pathlib.Path) -> None:
417 """
418 Appending to a file which doesn't contain a JSON array throws
419 a ValueError.
420 """
421 red_pentagon = {"sides": 5, "colour": "red"}
423 write_js(js_path, value=red_pentagon, varname="redPentagon")
425 with pytest.raises(ValueError, match="does not look like an array"):
426 append_to_js_array(js_path, value=["yellow"])
428 def test_error_if_path_doesnt_exist(self, js_path: pathlib.Path) -> None:
429 """
430 Appending to the path of a non-existent file throws FileNotFoundError.
431 """
432 with pytest.raises(FileNotFoundError):
433 append_to_js_array(js_path, value="alex")
435 def test_error_if_path_is_dir(self, tmp_path: pathlib.Path) -> None:
436 """
437 Appending to a path which is a directory throws IsADirectoryError.
438 """
439 with pytest.raises(IsADirectoryError):
440 append_to_js_array(tmp_path, value="alex")
442 def test_indentation_is_consistent(self, tmp_path: pathlib.Path) -> None:
443 """
444 If you append to an array, the file looks as if you'd read and rewritten
445 the whole thing with ``write_js()``.
446 """
447 js_path1 = tmp_path / "data1.js"
448 js_path2 = tmp_path / "data2.js"
450 # We use deliberately large value, so they won't be compressed
451 # by the custom encoder.
452 value = ["1" * 10, "2" * 20, "3" * 30]
453 appended_value = ["4" * 40, "5" * 50, "6" * 60]
455 write_js(js_path1, varname="numbers", value=value)
456 append_to_js_array(js_path1, value=appended_value)
458 write_js(js_path2, varname="numbers", value=value + [appended_value])
460 assert js_path1.read_text() == js_path2.read_text()
463class TestAppendToObject:
464 """
465 Tests for the ``append_to_js_object`` function.
466 """
468 @pytest.mark.parametrize(
469 "text",
470 [
471 'const redPentagon = {"colour": "red", "sides": 5};\n',
472 'const redPentagon = {"colour": "red", "sides": 5};',
473 'const redPentagon = {\n "colour": "red",\n "sides": 5\n};\n',
474 'const redPentagon = {\n "colour": "red",\n "sides": 5\n};',
475 'const redPentagon = {\n "colour": "red",\n "sides": 5\n}',
476 ],
477 )
478 def test_append_to_js_object(self, js_path: pathlib.Path, text: str) -> None:
479 """
480 After you add a key/value pair to an object, you can retrieve the
481 updated object.
483 This is true regardless of what the trailing whitespace in the
484 original file looks like.
485 """
486 js_path.write_text(text)
488 assert read_js(js_path, varname="redPentagon") == {"colour": "red", "sides": 5}
490 append_to_js_object(js_path, key="sideLengths", value=[1, 2, 3, 4, 5])
491 assert read_js(js_path, varname="redPentagon") == {
492 "colour": "red",
493 "sides": 5,
494 "sideLengths": [1, 2, 3, 4, 5],
495 }
497 def test_indentation_is_consistent(self, tmp_path: pathlib.Path) -> None:
498 """
499 If you append to an object, the file looks as if you'd read and
500 rewritten the whole thing with ``write_js()``.
501 """
502 js_path1 = tmp_path / "data1.js"
503 js_path2 = tmp_path / "data2.js"
505 # We pick a deliberately large value, so it won't be compressed
506 # by the custom encoder.
507 value = ["1" * 10, "2" * 20, "3" * 30]
509 write_js(js_path1, varname="shape", value={"colour": "red"})
510 append_to_js_object(js_path1, key="sides", value=value)
512 write_js(
513 js_path2,
514 varname="shape",
515 value={"colour": "red", "sides": value},
516 )
518 assert js_path1.read_text() == js_path2.read_text()
520 def test_error_if_file_doesnt_look_like_object(self, js_path: pathlib.Path) -> None:
521 """
522 Appending to a file which doesn't contain a JSON object throws
523 a ValueError.
524 """
525 shapes = ["apple", "banana", "cherry"]
527 write_js(js_path, value=shapes, varname="fruit")
529 with pytest.raises(ValueError, match="does not look like an object"):
530 append_to_js_object(js_path, key="sideLengths", value=[5, 5, 6, 6, 6])
532 def test_error_if_path_doesnt_exist(self, js_path: pathlib.Path) -> None:
533 """
534 Appending to the path of a non-existent file throws FileNotFoundError.
535 """
536 with pytest.raises(FileNotFoundError):
537 append_to_js_object(js_path, key="name", value="alex")
539 def test_error_if_path_is_dir(self, tmp_path: pathlib.Path) -> None:
540 """
541 Appending to a path which is a directory throws IsADirectoryError.
542 """
543 with pytest.raises(IsADirectoryError):
544 append_to_js_object(tmp_path, key="name", value="alex")
547class TestRoundTrip:
548 """
549 A "round trip" is a test that we can use one function to store a value,
550 and another function to retrieve it.
552 It checks that the functions are consistent with each other, and aren't
553 losing any information along the way.
554 """
556 @pytest.mark.parametrize(
557 "value",
558 [
559 "hello world",
560 5,
561 None,
562 ["1", "2", "3"],
563 {"colour": "red", "sides": 5},
564 'a string with "double quotes"',
565 "this is const myTestVariable",
566 "const myTestVariable = ",
567 ],
568 )
569 def test_can_read_and_write_value(
570 self, js_path: pathlib.Path, value: typing.Any
571 ) -> None:
572 """
573 After you write a value with ``write_js()``, you get the same value
574 back when you call ``read_js()``.
575 """
576 write_js(js_path, value=value, varname="myTestVariable")
577 assert read_js(js_path, varname="myTestVariable") == value
579 def test_can_append_to_file(self, js_path: pathlib.Path) -> None:
580 """
581 After you append a value to an array, you can read the entire file
582 and get the updated array.
583 """
584 write_js(js_path, value=["apple", "banana", "coconut"], varname="fruit")
585 append_to_js_array(js_path, value="damson")
586 assert read_js(js_path, varname="fruit") == [
587 "apple",
588 "banana",
589 "coconut",
590 "damson",
591 ]