diff --git a/nsv/ensv.py b/nsv/ensv.py new file mode 100644 index 0000000..37704b1 --- /dev/null +++ b/nsv/ensv.py @@ -0,0 +1,45 @@ +from typing import List, Iterable + +from .reader import Reader +from .writer import Writer + + +def lift(seqseq: Iterable[Iterable[str]]) -> List[str]: + """ + Lift a seqseq into a single row (sequence of strings), removing one + dimension without losing structural information. + + Escapes every cell and chains them with empty-string separators + between rows (separator semantics, not terminator). + """ + result = [] + first = True + for row in seqseq: + if not first: + result.append('') + for cell in row: + result.append(Writer.escape(cell)) + first = False + return result + + +def unlift(seq: Iterable[str]) -> List[List[str]]: + """ + Unlift a sequence of strings back into a seqseq, recovering the + dimension collapsed by lift. + + 1. If the current element is non-empty, unescape it and append to the + current row + 2. If the current element is empty, terminate the current row + 3. At end of input, terminate the current row + """ + rows = [] + row = [] + for element in seq: + if element != '': + row.append(Reader.unescape(element)) + else: + rows.append(row) + row = [] + rows.append(row) + return rows diff --git a/tests/test_lift.py b/tests/test_lift.py new file mode 100644 index 0000000..19776e9 --- /dev/null +++ b/tests/test_lift.py @@ -0,0 +1,145 @@ +import unittest + +from nsv.ensv import lift, unlift +from nsv.util import escape_seqseq, spill +from test_utils import SAMPLES_DATA + + +# All non-empty samples — [] is irrepresentable by lift. +LIFT_SAMPLES = {k: v for k, v in SAMPLES_DATA.items() if v} + + +class TestLift(unittest.TestCase): + + def test_single_row_single_cell(self): + self.assertEqual(['a'], lift([['a']])) + + def test_single_row_multiple_cells(self): + self.assertEqual(['a', 'b', 'c'], lift([['a', 'b', 'c']])) + + def test_multiple_rows(self): + self.assertEqual( + ['a', 'b', '', 'c', 'd'], + lift([['a', 'b'], ['c', 'd']]), + ) + + def test_ragged_rows(self): + self.assertEqual( + ['a', 'b', '', 'c'], + lift([['a', 'b'], ['c']]), + ) + + def test_single_empty_row(self): + """[[]] lifts to [] — the single empty row becomes an empty sequence.""" + self.assertEqual([], lift([[]])) + + def test_two_empty_rows(self): + self.assertEqual([''], lift([[], []])) + + def test_three_empty_rows(self): + self.assertEqual(['', ''], lift([[], [], []])) + + def test_empty_row_between_data(self): + self.assertEqual( + ['a', '', '', 'b'], + lift([['a'], [], ['b']]), + ) + + def test_escaping_backslash(self): + self.assertEqual(['a\\\\b'], lift([['a\\b']])) + + def test_escaping_newline(self): + self.assertEqual(['a\\nb'], lift([['a\nb']])) + + def test_escaping_empty_cell(self): + self.assertEqual(['\\'], lift([['']])) + + def test_escaping_multiple_specials(self): + result = lift([['a\\b', 'c\nd'], ['', 'e']]) + self.assertEqual(['a\\\\b', 'c\\nd', '', '\\', 'e'], result) + + +class TestUnlift(unittest.TestCase): + + def test_single_element(self): + self.assertEqual([['a']], unlift(['a'])) + + def test_multiple_elements_single_row(self): + self.assertEqual([['a', 'b', 'c']], unlift(['a', 'b', 'c'])) + + def test_multiple_rows(self): + self.assertEqual( + [['a', 'b'], ['c', 'd']], + unlift(['a', 'b', '', 'c', 'd']), + ) + + def test_empty_input(self): + """Empty input unlifts to [[]] — terminates the (empty) current row.""" + self.assertEqual([[]], unlift([])) + + def test_single_separator(self): + """A single empty string produces two empty rows.""" + self.assertEqual([[], []], unlift([''])) + + def test_unescaping_backslash(self): + self.assertEqual([['a\\b']], unlift(['a\\\\b'])) + + def test_unescaping_newline(self): + self.assertEqual([['a\nb']], unlift(['a\\nb'])) + + def test_unescaping_empty_cell(self): + self.assertEqual([['']], unlift(['\\'])) + + def test_trailing_separator(self): + self.assertEqual([['a'], []], unlift(['a', ''])) + + +class TestLiftUnliftRoundtrip(unittest.TestCase): + """unlift(lift(x)) == x for all non-empty seqseqs.""" + + def test_roundtrip(self): + for name, seqseq in LIFT_SAMPLES.items(): + with self.subTest(name=name): + self.assertEqual(seqseq, unlift(lift(seqseq))) + + def test_lift_produces_no_newlines(self): + for name, seqseq in LIFT_SAMPLES.items(): + with self.subTest(name=name): + for cell in lift(seqseq): + self.assertNotIn('\n', cell) + + +class TestUnliftLiftRoundtrip(unittest.TestCase): + """lift(unlift(y)) == y for valid lifted sequences.""" + + CASES = [ + ['a'], + ['a', 'b'], + ['a', 'b', '', 'c'], + ['a', '', '', 'b'], + [''], + ['', ''], + ['a', ''], + ['\\'], + ['a\\\\b', 'c\\nd'], + ['a\\\\b', 'c\\nd', '', '\\', 'e'], + ] + + def test_roundtrip(self): + for seq in self.CASES: + with self.subTest(seq=seq): + self.assertEqual(seq, lift(unlift(seq))) + + +class TestLiftDecomposition(unittest.TestCase): + """Verify equivalence: lift = init . spill[String, ''] . map(map(escape)).""" + + def test_equivalence(self): + for name, seqseq in LIFT_SAMPLES.items(): + with self.subTest(name=name): + via_decomposition = spill(escape_seqseq(seqseq), '')[:-1] + self.assertEqual(via_decomposition, lift(seqseq)) + + +if __name__ == '__main__': + unittest.main()