1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
require_relative "../test_helper"

require "hegel"

require "pro_tacts/note_sections"

# The text algebra of the one NOTE a served card carries, on its own
# with no contact and no group: what composition writes and what a PUT
# reads, per docs/plans/2026-09-25-one-note-per-contact.md. The join
# and the split it reads with are tested together where they agree, and
# each alone where they do not — the split tolerating the whitespace a
# person's editing moves, the join writing one shape only.
class NoteSectionsTest < Minitest::Test
  include Hegel::Syntax::Methods

  NEIGHBOURS = ProTacts::NoteSections.header("Neighbours") #: String

  def section(label, text) = ProTacts::NoteSections::Section.new(label:, text:)

  def split(value, labels) = ProTacts::NoteSections.split(value, labels)

  ## The join

  def test_member_text_and_one_section
    sections = [section("Neighbours", "bins go out on Tuesday")]

    assert_equal "gate code 1854\n\n#{NEIGHBOURS}\nbins go out on Tuesday",
      ProTacts::NoteSections.join("gate code 1854", sections)
  end

  def test_a_section_alone_loses_no_blank_it_never_had
    assert_equal "#{NEIGHBOURS}\nbins go out on Tuesday",
      ProTacts::NoteSections.join("", [section("Neighbours", "bins go out on Tuesday")])
  end

  def test_sections_come_in_the_order_given
    value = ProTacts::NoteSections.join(
      "",
      [section("Neighbours", "bins"), section("Dog walkers", "Sunday")],
    )

    assert_equal "#{NEIGHBOURS}\nbins\n\n#{ProTacts::NoteSections.header('Dog walkers')}\nSunday", value
  end

  def test_a_multiline_body_keeps_its_paragraphs
    value = ProTacts::NoteSections.join("own", [section("Neighbours", "first line\n\nsecond line")])

    assert_equal "own\n\n#{NEIGHBOURS}\nfirst line\n\nsecond line", value
  end

  def test_a_bodyless_section_is_its_header
    assert_equal "#{NEIGHBOURS}", ProTacts::NoteSections.join("", [section("Neighbours", "")])
  end

  ## The split

  def test_the_joined_shape_reads_back
    value = ProTacts::NoteSections.join(
      "gate code 1854\ncall before visiting",
      [section("Neighbours", "bins go out on Tuesday\ngreen bin alternates")],
    )

    read = split(value, ["Neighbours"])

    assert_equal "gate code 1854\ncall before visiting", read.member
    assert_equal [section("Neighbours", "bins go out on Tuesday\ngreen bin alternates")], read.sections
  end

  def test_a_label_nothing_lends_is_member_text
    read = split("pastedin\n#{NEIGHBOURS}\nnot lent", [])

    assert_equal "pastedin\n#{NEIGHBOURS}\nnot lent", read.member
    assert_empty read.sections
  end

  def test_whitespace_between_sections_is_not_structure
    read = split("own\n#{NEIGHBOURS}\nlent\n\n\n", ["Neighbours"])

    assert_equal "own", read.member
    assert_equal [section("Neighbours", "lent")], read.sections
  end

  def test_a_header_tolerates_trailing_whitespace
    read = split("own\n#{NEIGHBOURS}  \nlent", ["Neighbours"])

    assert_equal [section("Neighbours", "lent")], read.sections
  end

  def test_an_indented_header_is_member_text
    read = split("own\n #{NEIGHBOURS}\nlent", ["Neighbours"])

    assert_equal "own\n #{NEIGHBOURS}\nlent", read.member
    assert_empty read.sections
  end

  def test_blank_lines_around_the_member_are_the_separator
    read = split("\n\nown\n\n#{NEIGHBOURS}\nlent", ["Neighbours"])

    assert_equal "own", read.member
    assert_equal [section("Neighbours", "lent")], read.sections
  end

  def test_a_member_of_all_blanks_is_no_member
    read = split("\n\n#{NEIGHBOURS}\nlent", ["Neighbours"])

    assert_equal "", read.member
  end

  def test_two_sections_of_one_label_stay_two
    read = split("#{NEIGHBOURS}\nfirst\n\n#{NEIGHBOURS}\nsecond", ["Neighbours"])

    assert_equal [section("Neighbours", "first"), section("Neighbours", "second")], read.sections
  end

  def test_sections_read_in_the_order_they_stand
    read = split(
      "#{ProTacts::NoteSections.header('Dog walkers')}\nSunday\n\n#{NEIGHBOURS}\nbins",
      ["Neighbours", "Dog walkers"],
    )

    assert_equal [section("Dog walkers", "Sunday"), section("Neighbours", "bins")], read.sections
  end

  ## Property tests

  def labels = arrays(from_regex('[^\n]{1,12}', fullmatch: true), max_size: 4).map(&:uniq)

  # A text as the join is handed one: lines of anything, no blank line
  # at either end — the space between parts is the composer's — and no
  # line that reads as a header of a label in play, since one line
  # cannot be both a person's words and a section's start.
  def body(labels)
    headers = labels.map { ProTacts::NoteSections.header(it) }
    arrays(from_regex('[^\n]{0,12}', fullmatch: true), max_size: 4)
      .map { |lines| trim(lines).join("\n") }
      .filter { |text| text.split("\n").none? { headers.include?(it.rstrip) } }
  end

  def trim(lines)
    lines.drop_while { it.strip.empty? }.reverse.drop_while { it.strip.empty? }.reverse
  end

  # The two halves are one round trip (the module comment): what the
  # join writes, the split reads back as the member text and sections
  # it was made of, whatever labels nothing lends are also in play.
  def test_the_split_reads_back_what_the_join_wrote
    Hegel.test(test_cases: 1_000) do |tc|
      lending = tc.draw(labels)
      idle = tc.draw(labels) - lending
      member = tc.draw(body(lending + idle))
      sections = lending.map { section(it, tc.draw(body(lending + idle))) }

      read = split(ProTacts::NoteSections.join(member, sections), lending + idle)
      assert_equal ProTacts::NoteSections::Split.new(member:, sections:), read
    end
  end

  # Lines a person might leave in a note they edited: the headers
  # themselves, spelled right and nearly, blanks, and words.
  def edited_line(labels)
    one_of(
      sampled_from(labels).map { ProTacts::NoteSections.header(it) },
      sampled_from(labels).map { "#{ProTacts::NoteSections.header(it)} " },
      sampled_from(labels).map { " #{ProTacts::NoteSections.header(it)}" },
      sampled_from(["", " ", "\t", "\r"]),
      from_regex('[^\n]{0,12}', fullmatch: true),
    )
  end

  # Whatever a person left in the note, reading it once settles it:
  # the value the split's reading joins back into reads the same way
  # again, so a save followed by a serve followed by a save is not a
  # drift.
  def test_a_split_is_settled_by_one_reading
    Hegel.test(test_cases: 1_000) do |tc|
      in_play = tc.draw(labels.filter(&:any?))
      value = tc.draw(arrays(edited_line(in_play), max_size: 10)).join("\n")
      read = split(value, in_play)

      assert_equal read, split(ProTacts::NoteSections.join(read.member, read.sections), in_play)
    end
  end
end