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
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
require "fileutils"
require "pathname"

require "pro_tacts/store"
require "pro_tacts/vcard"

# Builds the data directory the tests and `rake fixtures` run against: a
# database seeded from the cards in test/fixtures/cards, rebuilt from
# scratch every time so a replay never depends on what a previous run
# left behind.
#
# The cards are files rather than a checked-in database because they are
# evidence, the same standing the request files in macos-exchange have:
# a reviewer can read a .vcf and a diff means something. The database is
# derived from them, and lives in a throwaway tmpdir with everything
# else that does.
module FixtureData
  CARDS = Pathname.new(__dir__) / "fixtures" / "cards"

  # The first card is stamped now and each one after it is #SPREAD times
  # older than the last, so the seed book runs from this minute out to
  # about thirty years at twenty-one cards. Computed rather than tabulated
  # because a table runs out: adding cards past the end of one either
  # wraps — two cards sharing a stamp, and a recently-updated list that
  # opens on a tie — or leaves the tail unstamped. Spread rather than
  # exact: what backdate wants is variety for the admin UI's
  # recently-updated list, not specific values for anything to assert on.
  SPREAD = 2.4

  #: (Integer index) -> Integer
  def self.ago_seconds(index)
    index.zero? ? 0 : (60 * SPREAD**(index - 1)).round
  end

  # Every seed card, keyed by the id its filename gives it.
  def self.cards
    CARDS.glob("*.vcf").sort.to_h { [it.basename(".vcf").to_s, it.read] }
  end

  # What the household shares: an address and a note, which is both
  # what a group may hold (see db/migrations/004_groups.rb) and the
  # shape the groups work tests against
  # (docs/plans/2026-08-24-vcard-storage-and-groups.md). Deliberately
  # not the address any seed card already carries, so a test can tell
  # a composed line from a stored one by its bytes.
  HOUSEHOLD = [
    "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom",
    "NOTE:Gate code 1854. The dog is friendly\\, the goose is not.",
  ].freeze #: Array[String]

  # The groups the fixture book carries. The first is a household, the
  # three household-* cards in it. Those cards are a family — one
  # surname, a mobile and an email each, and no address of their own —
  # so the address row the admin UI shows on them is the group's, and
  # removing a member visibly takes it away. Alicia carries a note of
  # her own beside the group's, the one shape the one-note composition
  # exists for (docs/plans/2026-09-25-one-note-per-contact.md): her
  # served card joins both into a single NOTE, and her page shows them
  # as two provenance-marked rows. They sit outside the recorded macOS
  # exchange's hrefs, so the replay keeps serving that session the
  # bytes it saw.
  #
  # The second is the nameless case: every bday-* card in one group
  # with no name and nothing to lend
  # (db/migrations/005_group_identity.rb). It moves no member's bytes —
  # a group that holds nothing composes nothing, so the replay is
  # untouched by it — and what it is here for is the surfaces that show
  # a group rather than a card, which have a group with no name to
  # render as its id (docs/DESIGN.md, "Groups are first-class
  # records").
  #
  # Seeded in Ruby rather than a fixture file because the cards are the
  # only evidence-shaped fixtures — a group is rows across three
  # tables with no file format of its own. The group itself is
  # Store#create_group's, which mints the id no caller gets to choose;
  # its properties and members land the way backdate's rows do, through
  # the store's own database rather than Store#set_group_lines and
  # #add_member. Those log a `group` entry for every member they move,
  # and the replay's sync tokens are read off that log — seeding
  # through them would hand the recorded session a history it never
  # saw.
  MEMBERS = %w[household-george household-mary household-alicia].freeze #: Array[String]

  # The seed's birthday shapes, read off the card ids rather than
  # listed again, so a shape added to test/fixtures/cards joins the
  # group by being named like its neighbours.
  #: () -> Array[String]
  def self.birthday_cards
    cards.keys.grep(/\Abday-/)
  end

  # The seed's cards that no client syncs, read off the ids for
  # birthday_cards' reason.
  #: () -> Array[String]
  def self.unsynced_cards
    cards.keys.grep(/\Aunsynced-/)
  end

  # A household nobody syncs yet: unsynced cards in a group with no
  # `sync:` prefix, so joining them to a book is one rename in the admin UI.
  CURIES = %w[unsynced-marie unsynced-pierre].freeze #: Array[String]

  # The clubs a family accumulates, and the reason the seed holds
  # thirty of them: the groups dialog opens on eight rows and hides the
  # rest (Admin::GroupDialog::LIMIT,
  # docs/plans/2026-09-22-a-few-groups-at-a-time.md), which is nothing
  # to look at in a book with four groups. Named rather than numbered,
  # because what the filter is given is a name somebody is typing and
  # "Group 12" exercises a search box against a string no one would
  # type. Several share an opening — Book club and Boulderers and
  # Brass band, Cheese share and Chess club and Choir — so a filter can
  # be narrowed twice and still have somewhere to go.
  #
  # None of them carries a line. A group with properties composes them
  # into its members' served bytes, and the recorded exchanges are a
  # session that saw those cards without these groups in the book, so
  # anything lent here is a replay diff (see MEMBERS).
  CLUBS = [
    "Allotment", "Book club", "Boulderers", "Brass band", "Cheese share", "Chess club",
    "Choir", "Cycling", "Dentists", "Dog walkers", "Film night", "Fishing", "Gardeners",
    "Hiking", "Knitting", "Locksmiths", "Neighbours", "Pub quiz", "Rowing", "Running club",
    "Sailing", "School run", "Swimming", "Tennis", "Ukulele", "Vets", "Walkers",
    "Wine club", "Yoga", "Zebrafish lab"
  ].freeze #: Array[String]

  # The one club seeded with a household in it, and last in the book by
  # name on purpose: a dialog opened on a Boole then has a group it is
  # already in sorting well past the cap, which is the row the cap is
  # not allowed to take (Admin::GroupDialog#initialize).
  ZOO = "Zoo volunteers" #: String

  # The third is `sync:*`, holding every card but the unsynced ones, so
  # every user's book is the rest of the seed — the replay's included,
  # whose recorded sessions saw every card they asked for — and the
  # admin UI still shows a contact that is in nobody's
  # (docs/plans/2026-09-12-per-user-books.md). The fourth is CURIES'.
  # The rest are CLUBS and ZOO.
  #: (ProTacts::Store store) -> void
  def self.seed_groups(store)
    seed_group(store, name: "Booles", lines: HOUSEHOLD, members: MEMBERS)
    seed_group(store, members: birthday_cards)
    seed_group(store, name: ProTacts::Group::EVERYONE, members: cards.keys - unsynced_cards)
    seed_group(store, name: "Curies", members: CURIES)
    seed_clubs(store)
  end

  # CLUBS, each holding a different few cards, so the list a group
  # screen shows and the chips a contact's row carries both vary. Rolled
  # off the card order rather than tabulated per club, for the reason
  # ago_seconds is computed: a table of memberships is a second list to
  # keep in step with test/fixtures/cards.
  #: (ProTacts::Store store) -> void
  def self.seed_clubs(store)
    ids = cards.keys
    CLUBS.each_with_index do |name, index|
      seed_group(store, name:, members: ids.rotate(index).take(index % 4))
    end
    seed_group(store, name: ZOO, members: MEMBERS)
  end

  # A group, seeded without a change-log entry for any member it moves
  # (see MEMBERS for why). The group's own log — which nothing replays
  # or syncs from — keeps the create's entry, which is true: the group
  # really was created; its lines and members land unlogged. Tests
  # seed theirs here too, so the cards' change log they assert on holds
  # only what the test wrote.
  #: (ProTacts::Store store, ?name: String?, ?lines: Array[String], ?members: Array[String]) -> String
  def self.seed_group(store, name: nil, lines: [], members: [])
    group_id = store.create_group(name:)
    lines.each.with_index do |line, position|
      database(store)[:group_properties].insert(group_id:, position:, line:)
    end
    members.each do |card_id|
      database(store)[:group_members].insert(group_id:, card_id:)
    end
    group_id
  end

  # The store's own database, which nothing public exposes and nothing
  # public should; seeding is the one reason to reach it.
  #: (ProTacts::Store store) -> Sequel::Database
  def self.database(store)
    store.instance_variable_get(:@database)
  end

  # Builds the database and returns a store still open on it, for the
  # caller to hand to the app the way config.ru does. `genesis` pins the
  # first change's stamp, and with it the database id its sync tokens
  # embed (Store#database_id): a recorded session's responses are byte
  # for byte the replay's only if both books answer as the same
  # database, and a real seed moment never is twice. Unpinned by
  # default — `rake dev` reseeding is what a fresh id per start is for
  # (ExchangeFixtures::GENESIS for the pinned spelling).
  def self.install(directory, genesis: nil)
    directory = Pathname.new(directory)
    FileUtils.rm_rf(directory)
    FileUtils.mkdir_p(directory)

    store = ProTacts::Store.at(directory / "contacts.db")
    seed = cards
    seed.each do |id, card|
      store.put(id, ProTacts::VCard.new(card))
    end
    database(store)[:changes].where(sequence: 1).update(created_at: genesis) if genesis
    backdate(store, seed.keys)
    seed_groups(store)
    store
  end

  # Store#put always asks SQLite for the current time (see the comment
  # on ProTacts::Store::NOW) — on purpose, since a real write's stamp
  # has to be the database's clock, not Ruby's idea of one. A fixture
  # book installed in one quick burst would otherwise land every card
  # within the same millisecond, and "recently updated" would show
  # nothing else. Fixture data is synthetic by definition, so backdating
  # it here is not the write path that invariant protects — reached the
  # same way a test does, through the store's own database (see
  # StoreTest#database in test/pro_tacts/test_store.rb), because nothing
  # public exposes it and nothing public should.
  def self.backdate(store, ids)
    now = Time.now.utc
    ids.each_with_index do |id, index|
      stamp = (now - ago_seconds(index)).strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
      database(store)[:cards].where(id:).update(updated_at: stamp)
    end
  end
end