wp2txt 2.1.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+ require "json"
7
+ require_relative "support/multistream_fixture"
8
+ require_relative "../lib/wp2txt/fts_index"
9
+ require_relative "../lib/wp2txt/corpus"
10
+
11
+ RSpec.describe "Wp2txt Full-Text Search" do
12
+ include MultistreamFixture
13
+
14
+ def build_indexes(dir, tokenizer: "unicode61", optimize: true)
15
+ multistream_path, index_path = create_fixture(dir)
16
+ ms_index = Wp2txt::MultistreamIndex.new(index_path, use_cache: false, show_progress: false)
17
+
18
+ meta_db = Wp2txt::MetadataIndex.path_for(multistream_path, cache_dir: dir)
19
+ Wp2txt::MetadataIndexBuilder.new(
20
+ multistream_path, ms_index.stream_offsets, db_path: meta_db, num_processes: 0
21
+ ).build
22
+
23
+ fts_db = Wp2txt::FtsIndex.path_for(multistream_path, cache_dir: dir)
24
+ Wp2txt::FtsIndexBuilder.new(
25
+ multistream_path, ms_index.stream_offsets,
26
+ db_path: fts_db, meta_db_path: meta_db, tokenizer: tokenizer,
27
+ num_processes: 0, optimize: optimize
28
+ ).build
29
+
30
+ [multistream_path, fts_db, meta_db]
31
+ end
32
+
33
+ describe Wp2txt::FtsIndex do
34
+ describe ".default_tokenizer" do
35
+ it "picks trigram for CJK language dumps" do
36
+ expect(described_class.default_tokenizer("/x/jawiki-20260701-multistream.xml.bz2")).to eq("trigram")
37
+ expect(described_class.default_tokenizer("/x/zhwiki-20260701-multistream.xml.bz2")).to eq("trigram")
38
+ end
39
+
40
+ it "picks unicode61 for space-delimited language dumps" do
41
+ expect(described_class.default_tokenizer("/x/enwiki-20260701-multistream.xml.bz2")).to eq("unicode61")
42
+ expect(described_class.default_tokenizer("/x/dewiki-20260701-multistream.xml.bz2")).to eq("unicode61")
43
+ end
44
+ end
45
+
46
+ context "with a built unicode61 index" do
47
+ around do |example|
48
+ Dir.mktmpdir do |dir|
49
+ @multistream_path, fts_db, meta_db = build_indexes(dir)
50
+ @fts = described_class.new(fts_db, meta_db)
51
+ example.run
52
+ @fts.close
53
+ end
54
+ end
55
+
56
+ it "is built, valid, and records the tokenizer" do
57
+ expect(@fts.built?).to be true
58
+ expect(@fts.valid_for?(@multistream_path)).to be true
59
+ expect(@fts.tokenizer).to eq("unicode61")
60
+ expect(@fts.stats[:section_count]).to be > 0
61
+ end
62
+
63
+ it "finds matches across articles with exact counts" do
64
+ result = @fts.search("Story", count: "exact")
65
+ expect(result[:total]).to eq(2)
66
+ expect(result[:total_is_capped]).to be false
67
+ expect(result[:hits].map { |h| h[:title] }).to contain_exactly("Film A", "Film B")
68
+ end
69
+
70
+ it "searches lead sections (empty heading)" do
71
+ result = @fts.search("Intro", count: "exact")
72
+ expect(result[:total]).to eq(2)
73
+ expect(result[:hits].map { |h| h[:heading] }.uniq).to eq([""])
74
+ end
75
+
76
+ it "excludes redirect pages from the index" do
77
+ result = @fts.search("Old Film", count: "exact")
78
+ expect(result[:total]).to eq(0)
79
+ end
80
+
81
+ it "returns zero for absent strings (absence claim)" do
82
+ expect(@fts.search("zebra unicorn", count: "exact")[:total]).to eq(0)
83
+ end
84
+
85
+ it "composes with an exact category filter" do
86
+ result = @fts.search("Story", category: "Japanese films", count: "exact")
87
+ expect(result[:hits].map { |h| h[:title] }).to eq(["Film A"])
88
+ end
89
+
90
+ it "composes with a recursive category filter" do
91
+ result = @fts.search("Story", category: "Films", depth: 1, count: "exact")
92
+ expect(result[:total]).to eq(2)
93
+ end
94
+
95
+ it "composes with a section filter" do
96
+ result = @fts.search("Story", sections: ["Plot"], count: "exact")
97
+ expect(result[:hits].map { |h| h[:heading] }).to eq(["Plot"])
98
+ end
99
+
100
+ it "normalizes decorated headings the same way as the metadata index" do
101
+ result = @fts.search("Distinct prose", count: "exact")
102
+ expect(result[:hits].map { |h| h[:heading] }).to eq(["Style"])
103
+ expect(@fts.search("Distinct prose", sections: ["Style"], count: "exact")[:total]).to eq(1)
104
+ end
105
+
106
+ it "keeps ord aligned between page_sections and fts_map (shared semantics)" do
107
+ meta_db = SQLite3::Database.new(@fts.meta_db_path, readonly: true)
108
+ meta_ord = meta_db.get_first_value(
109
+ "SELECT ps.ord FROM page_sections ps JOIN pages p ON p.page_id = ps.page_id " \
110
+ "WHERE p.title = 'Person X' AND ps.heading = 'Career'"
111
+ )
112
+ meta_db.close
113
+ fts_db = SQLite3::Database.new(@fts.db_path, readonly: true)
114
+ fts_ord = fts_db.get_first_value(
115
+ "SELECT fm.ord FROM fts_map fm WHERE fm.heading = 'Career'"
116
+ )
117
+ fts_db.close
118
+ expect(meta_ord).to eq(1) # lead = 0 (not stored), first heading = 1
119
+ expect(fts_ord).to eq(meta_ord)
120
+ end
121
+
122
+ it "caps counting when requested" do
123
+ result = @fts.search("Story", count: "capped", count_cap: 1)
124
+ expect(result[:total]).to eq(1)
125
+ expect(result[:total_is_capped]).to be true
126
+ end
127
+
128
+ it "supports raw FTS5 query mode" do
129
+ result = @fts.search("Story OR Acting", mode: "query", count: "exact")
130
+ expect(result[:total]).to eq(3)
131
+ end
132
+
133
+ it "escapes quotes in phrase mode" do
134
+ expect { @fts.search('say "hi" now', count: "exact") }.not_to raise_error
135
+ end
136
+ end
137
+
138
+ context "built with optimize: false" do
139
+ around do |example|
140
+ Dir.mktmpdir do |dir|
141
+ @multistream_path, fts_db, meta_db = build_indexes(dir, optimize: false)
142
+ @fts = described_class.new(fts_db, meta_db)
143
+ example.run
144
+ @fts.close
145
+ end
146
+ end
147
+
148
+ it "is valid, flagged unoptimized, and fully searchable" do
149
+ expect(@fts.built?).to be true
150
+ expect(@fts.valid_for?(@multistream_path)).to be true
151
+ expect(@fts.optimized?).to be false
152
+ expect(@fts.stats[:optimized]).to be false
153
+ expect(@fts.search("Story", count: "exact")[:total]).to eq(2)
154
+ end
155
+
156
+ it "can be optimized afterwards (idempotent)" do
157
+ expect(@fts.optimize!).to be true
158
+ expect(@fts.optimized?).to be true
159
+ expect(@fts.search("Story", count: "exact")[:total]).to eq(2)
160
+ expect(@fts.optimize!).to be true
161
+ end
162
+ end
163
+
164
+ context "with a trigram index" do
165
+ around do |example|
166
+ Dir.mktmpdir do |dir|
167
+ @multistream_path, fts_db, meta_db = build_indexes(dir, tokenizer: "trigram")
168
+ @fts = described_class.new(fts_db, meta_db)
169
+ example.run
170
+ @fts.close
171
+ end
172
+ end
173
+
174
+ it "matches substrings of three or more characters" do
175
+ result = @fts.search("tory", count: "exact")
176
+ expect(result[:total]).to eq(2)
177
+ end
178
+ end
179
+ end
180
+
181
+ describe "Corpus#search_text" do
182
+ around do |example|
183
+ Dir.mktmpdir do |dir|
184
+ @multistream_path, = build_indexes(dir)
185
+ @corpus = Wp2txt::Corpus.for_input(@multistream_path, cache_dir: dir)
186
+ example.run
187
+ @corpus.close
188
+ end
189
+ end
190
+
191
+ it "reports the fulltext tier in dump_info" do
192
+ info = @corpus.dump_info
193
+ expect(info[:tiers][:fulltext]).to be true
194
+ expect(info[:fulltext_current]).to be true
195
+ expect(info[:fulltext][:tokenizer]).to eq("unicode61")
196
+ end
197
+
198
+ it "exposes the fts tables to query_sql" do
199
+ result = @corpus.query_sql("SELECT COUNT(*) FROM fts.fts_map")
200
+ expect(result[:rows].first.first).to be > 0
201
+ expect(@corpus.describe_schema[:fts].join).to include("fts_map")
202
+ end
203
+
204
+ it "returns hits with section paths and dump identity" do
205
+ result = @corpus.search_text("Story", count: "exact")
206
+ expect(result[:dump]).to eq("testwiki-20260101")
207
+ expect(result[:total]).to eq(2)
208
+ paths = result[:hits].map { |h| h[:section_path] }
209
+ expect(paths).to contain_exactly("Film A > Plot", "Film B > Synopsis")
210
+ end
211
+
212
+ it "renders snippets containing the search term" do
213
+ result = @corpus.search_text("Story")
214
+ expect(result[:hits].first[:snippet]).to include("Story")
215
+ end
216
+
217
+ it "uses the article title as section_path for lead hits" do
218
+ result = @corpus.search_text("Intro", count: "exact")
219
+ expect(result[:hits].map { |h| h[:section_path] }).to contain_exactly("Film A", "Film B")
220
+ expect(result[:hits].first).not_to have_key(:section)
221
+ end
222
+
223
+ it "expands section filters through alias sets" do
224
+ @corpus.save_alias_set("plot", [%w[Plot Synopsis]], min_articles: 1)
225
+ result = @corpus.search_text("Story", sections: ["Plot"], alias_set: "plot", count: "exact")
226
+ expect(result[:total]).to eq(2)
227
+ end
228
+
229
+ it "raises a helpful error when the index is missing" do
230
+ Dir.mktmpdir do |dir2|
231
+ ms2, = create_fixture(dir2)
232
+ ms_index = Wp2txt::MultistreamIndex.new(
233
+ ms2.sub(/multistream\.xml\.bz2\z/, "multistream-index.txt"), use_cache: false, show_progress: false
234
+ )
235
+ Wp2txt::MetadataIndexBuilder.new(
236
+ ms2, ms_index.stream_offsets,
237
+ db_path: Wp2txt::MetadataIndex.path_for(ms2, cache_dir: dir2), num_processes: 0
238
+ ).build
239
+ corpus2 = Wp2txt::Corpus.for_input(ms2, cache_dir: dir2)
240
+ expect { corpus2.search_text("x") }.to raise_error(ArgumentError, /Full-text index not built/)
241
+ corpus2.close
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+ require "zlib"
6
+ require_relative "support/multistream_fixture"
7
+ require_relative "support/meta_db_fixture"
8
+ require_relative "../lib/wp2txt/langlinks_importer"
9
+ require_relative "../lib/wp2txt/multistream"
10
+
11
+ RSpec.describe Wp2txt::LanglinksImporter do
12
+ include MultistreamFixture
13
+ include MetaDbFixture
14
+
15
+ around do |example|
16
+ Dir.mktmpdir do |dir|
17
+ @dir = dir
18
+ @multistream_path, @index_path = create_fixture(dir)
19
+
20
+ ms_index = Wp2txt::MultistreamIndex.new(@index_path, use_cache: false, show_progress: false)
21
+ @db_path = Wp2txt::MetadataIndex.path_for(@multistream_path, cache_dir: dir)
22
+ Wp2txt::MetadataIndexBuilder.new(
23
+ @multistream_path, ms_index.stream_offsets,
24
+ db_path: @db_path, num_processes: 0
25
+ ).build
26
+ example.run
27
+ end
28
+ end
29
+
30
+ # MySQL dump content covering the tricky cases: escaped quotes/backslashes,
31
+ # commas and parens inside titles, underscores, multiple INSERT statements,
32
+ # and an INSERT for a different table (must be ignored)
33
+ LANGLINKS_SQL = <<~SQL
34
+ -- MySQL dump fixture
35
+ CREATE TABLE `langlinks` (
36
+ `ll_from` int unsigned NOT NULL DEFAULT 0,
37
+ `ll_lang` varbinary(20) NOT NULL DEFAULT '',
38
+ `ll_title` varbinary(255) NOT NULL DEFAULT ''
39
+ ) ENGINE=InnoDB DEFAULT CHARSET=binary;
40
+
41
+ INSERT INTO `langlinks` VALUES (1,'en','Film A'),(1,'de','Film A (Film)'),(2,'en','It\\'s a Film, Really (1984)');
42
+ INSERT INTO `langlinks` VALUES (3,'en','Back\\\\slash Title'),(3,'fr','Film B'),(4,'en','Underscore_title');
43
+ INSERT INTO `pagelinks` VALUES (1,'Foo',0);
44
+ UNLOCK TABLES;
45
+ SQL
46
+
47
+ def write_langlinks(name, content = LANGLINKS_SQL, gzip: false)
48
+ path = File.join(@dir, name)
49
+ if gzip
50
+ Zlib::GzipWriter.open(path) { |gz| gz.write(content) }
51
+ else
52
+ File.write(path, content)
53
+ end
54
+ path
55
+ end
56
+
57
+ def import(path, **opts)
58
+ described_class.new(@db_path, cache_dir: @dir).import!(path, **opts)
59
+ end
60
+
61
+ def langlinks_rows
62
+ db = SQLite3::Database.new(@db_path, readonly: true)
63
+ rows = db.execute("SELECT ll_from, ll_lang, ll_title FROM langlinks ORDER BY ll_from, ll_lang")
64
+ db.close
65
+ rows
66
+ end
67
+
68
+ describe "parsing and normalization" do
69
+ it "imports tuples with escapes, commas, parens, and multiple INSERT statements" do
70
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
71
+ result = import(path)
72
+
73
+ expect(result[:status]).to eq(:imported)
74
+ expect(result[:row_count]).to eq(6)
75
+ expect(langlinks_rows).to contain_exactly(
76
+ [1, "en", "Film A"],
77
+ [1, "de", "Film A (Film)"],
78
+ [2, "en", "It's a Film, Really (1984)"],
79
+ [3, "en", 'Back\slash Title'],
80
+ [3, "fr", "Film B"],
81
+ [4, "en", "Underscore title"]
82
+ )
83
+ end
84
+
85
+ it "reads .sql.gz files" do
86
+ path = write_langlinks("testwiki-20260101-langlinks.sql.gz", gzip: true)
87
+ expect(import(path)[:row_count]).to eq(6)
88
+ end
89
+
90
+ it "creates both indexes after the load" do
91
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
92
+ import(path)
93
+ db = SQLite3::Database.new(@db_path, readonly: true)
94
+ indexes = db.execute("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'langlinks'").flatten
95
+ db.close
96
+ expect(indexes).to contain_exactly("idx_langlinks_from", "idx_langlinks_lang_title")
97
+ end
98
+
99
+ it "keeps all rows across batch boundaries" do
100
+ stub_const("Wp2txt::LanglinksImporter::BATCH_SIZE", 2)
101
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
102
+ expect(import(path)[:row_count]).to eq(6)
103
+ expect(langlinks_rows.size).to eq(6)
104
+ end
105
+
106
+ it "imports multibyte (ja/zh/ko) titles, including escapes and underscores" do
107
+ sql = <<~SQL
108
+ INSERT INTO `langlinks` VALUES (1,'ja','宇宙戦艦ヤマト'),(2,'zh','粵語標題(電影)'),(3,'ko','한국어_제목'),(4,'ja','It\\'s 映画, Really(1984)');
109
+ SQL
110
+ path = write_langlinks("testwiki-20260101-langlinks.sql", sql)
111
+ result = import(path)
112
+
113
+ expect(result[:row_count]).to eq(4)
114
+ expect(langlinks_rows).to contain_exactly(
115
+ [1, "ja", "宇宙戦艦ヤマト"],
116
+ [2, "zh", "粵語標題(電影)"],
117
+ [3, "ko", "한국어 제목"],
118
+ [4, "ja", "It's 映画, Really(1984)"]
119
+ )
120
+ end
121
+
122
+ it "parses large multibyte INSERT lines fast enough (performance regression)" do
123
+ # A single ~200KB+ extended INSERT line full of multibyte titles: the
124
+ # old character-index parser was O(n²) here (minutes), regex scan is O(n)
125
+ titles = ["宇宙戦艦ヤマト(映画)", "粵語標題(電影, 1984)", "한국어 제목", "時間の旅, それから"]
126
+ tuples = Array.new(6_000) { |i| "(#{i + 1},'ja','#{titles[i % titles.size]}')" }
127
+ sql = +"INSERT INTO `langlinks` VALUES " << tuples.join(",") << ";\n"
128
+ expect(sql.bytesize).to be > 200_000
129
+
130
+ path = write_langlinks("testwiki-20260101-langlinks.sql", sql)
131
+ importer = described_class.new(@db_path, cache_dir: @dir)
132
+ count = 0
133
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
134
+ importer.send(:each_source_row, path) { count += 1 }
135
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
136
+
137
+ expect(count).to eq(6_000)
138
+ expect(elapsed).to be < 0.5
139
+ end
140
+
141
+ it "skips and counts rows whose title contains invalid UTF-8 bytes" do
142
+ # Real dumps contain historically corrupted bytes in ll_title (VARBINARY)
143
+ sql = +"INSERT INTO `langlinks` VALUES (1,'en','Film A'),(2,'en','Bad\xFFTitle'),(3,'fr','Film B');\n"
144
+ path = write_langlinks("testwiki-20260101-langlinks.sql", sql.b)
145
+ result = import(path)
146
+
147
+ expect(result[:status]).to eq(:imported)
148
+ expect(result[:row_count]).to eq(2)
149
+ expect(result[:skipped_invalid]).to eq(1)
150
+ expect(langlinks_rows).to contain_exactly([1, "en", "Film A"], [3, "fr", "Film B"])
151
+ expect(result[:provenance][:skipped_invalid]).to eq(1)
152
+ end
153
+
154
+ it "skips and counts rows whose language code contains invalid UTF-8 bytes" do
155
+ sql = +"INSERT INTO `langlinks` VALUES (1,'e\xFFn','Film A'),(2,'en','Film B');\n"
156
+ path = write_langlinks("testwiki-20260101-langlinks.sql", sql.b)
157
+ result = import(path)
158
+
159
+ expect(result[:row_count]).to eq(1)
160
+ expect(result[:skipped_invalid]).to eq(1)
161
+ expect(langlinks_rows).to eq([[2, "en", "Film B"]])
162
+ end
163
+
164
+ it "handles invalid bytes in .sql.gz input as well" do
165
+ sql = +"INSERT INTO `langlinks` VALUES (1,'en','Bad\xFFTitle'),(2,'en','Film B');\n"
166
+ path = write_langlinks("testwiki-20260101-langlinks.sql.gz", sql.b, gzip: true)
167
+ result = import(path)
168
+
169
+ expect(result[:row_count]).to eq(1)
170
+ expect(result[:skipped_invalid]).to eq(1)
171
+ end
172
+
173
+ it "filters target languages with the langs option" do
174
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
175
+ result = import(path, langs: %w[en fr])
176
+
177
+ expect(result[:row_count]).to eq(5)
178
+ expect(langlinks_rows.map { |r| r[1] }.uniq).to contain_exactly("en", "fr")
179
+ expect(result[:provenance][:lang_filter]).to eq("en,fr")
180
+ end
181
+ end
182
+
183
+ describe "version pinning" do
184
+ it "rejects a langlinks file whose dump name differs from the metadata DB" do
185
+ path = write_langlinks("testwiki-20260102-langlinks.sql")
186
+ expect { import(path) }.to raise_error(ArgumentError, /version mismatch/)
187
+ end
188
+
189
+ it "rejects a mismatched file even with force (no override)" do
190
+ path = write_langlinks("testwiki-20260102-langlinks.sql")
191
+ expect { import(path, force: true) }.to raise_error(ArgumentError, /version mismatch/)
192
+ end
193
+
194
+ it "rejects files whose name carries no dump name" do
195
+ path = write_langlinks("langlinks.sql")
196
+ expect { import(path) }.to raise_error(ArgumentError, /version mismatch/)
197
+ end
198
+ end
199
+
200
+ describe "re-import" do
201
+ it "is a no-op when already imported (reports imported_at)" do
202
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
203
+ first = import(path)
204
+ second = import(path)
205
+
206
+ expect(second[:status]).to eq(:already_imported)
207
+ expect(second[:imported_at]).to eq(first[:provenance][:imported_at])
208
+ expect(second[:row_count]).to eq(6)
209
+ end
210
+
211
+ it "drops and re-imports with force, refreshing the provenance" do
212
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
213
+ import(path)
214
+
215
+ smaller = <<~SQL
216
+ INSERT INTO `langlinks` VALUES (1,'en','Film A');
217
+ SQL
218
+ path2 = write_langlinks("testwiki-20260101-langlinks.sql", smaller)
219
+ result = import(path2, force: true)
220
+
221
+ expect(result[:status]).to eq(:imported)
222
+ expect(result[:row_count]).to eq(1)
223
+ expect(langlinks_rows).to eq([[1, "en", "Film A"]])
224
+ expect(result[:provenance][:row_count]).to eq(1)
225
+ end
226
+
227
+ it "clears stale provenance when a forced re-import fails midway" do
228
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
229
+ expect(import(path)[:status]).to eq(:imported)
230
+
231
+ # Simulate a failure during the load (corrupt bytes, disk full, ...),
232
+ # after the first batch has already been committed
233
+ stub_const("Wp2txt::LanglinksImporter::BATCH_SIZE", 1)
234
+ failing = described_class.new(@db_path, cache_dir: @dir)
235
+ allow(failing).to receive(:each_source_row) do |_source, &block|
236
+ block.call(1, "en", "Film A")
237
+ raise IOError, "simulated read failure"
238
+ end
239
+ expect { failing.import!(path, force: true) }.to raise_error(IOError)
240
+
241
+ # Partial table, but provenance is gone: judged as "not imported"
242
+ expect(langlinks_rows).to eq([[1, "en", "Film A"]])
243
+ meta = Wp2txt::MetadataIndex.new(@db_path)
244
+ expect(meta.langlinks_provenance).to be_nil
245
+ meta.close
246
+
247
+ # A non-force retry re-imports instead of reporting a stale success
248
+ retry_result = import(path)
249
+ expect(retry_result[:status]).to eq(:imported)
250
+ expect(retry_result[:row_count]).to eq(6)
251
+ end
252
+ end
253
+
254
+ describe "provenance" do
255
+ it "stamps source, size, time, tool version, filter, and row count" do
256
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
257
+ prov = import(path)[:provenance]
258
+
259
+ expect(prov[:source]).to eq("testwiki-20260101-langlinks.sql")
260
+ expect(prov[:source_size]).to eq(File.size(path))
261
+ expect(prov[:imported_at]).to match(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
262
+ expect(prov[:imported_with]).to eq(Wp2txt::VERSION)
263
+ expect(prov[:lang_filter]).to eq("all")
264
+ expect(prov[:row_count]).to eq(6)
265
+
266
+ meta = Wp2txt::MetadataIndex.new(@db_path)
267
+ expect(meta.langlinks_provenance[:row_count]).to eq(6)
268
+ meta.close
269
+ end
270
+ end
271
+
272
+ describe "sanity check" do
273
+ it "is skipped when the target language has no local meta DB" do
274
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
275
+ expect(import(path)[:sanity]).to eq([])
276
+ end
277
+
278
+ it "reports the join rate against an installed target meta DB" do
279
+ create_meta_db(@dir, lang: "en", date: "20260101",
280
+ pages: [[101, "Film A", 0, nil, 10], [102, "Film B", 0, nil, 10]])
281
+ sql = <<~SQL
282
+ INSERT INTO `langlinks` VALUES (1,'en','Film A'),(2,'en','Film B');
283
+ SQL
284
+ path = write_langlinks("testwiki-20260101-langlinks.sql", sql)
285
+ result = import(path)
286
+
287
+ expect(result[:sanity].size).to eq(1)
288
+ check = result[:sanity].first
289
+ expect(check[:lang]).to eq("en")
290
+ expect(check[:match_rate]).to eq(1.0)
291
+ expect(check[:warning]).to be false
292
+ expect(check[:against]).to eq("enwiki-20260101")
293
+ end
294
+
295
+ it "warns when the join rate falls below 90%" do
296
+ create_meta_db(@dir, lang: "en", date: "20260101",
297
+ pages: [[101, "Something Else", 0, nil, 10]])
298
+ sql = <<~SQL
299
+ INSERT INTO `langlinks` VALUES (1,'en','Film A'),(2,'en','Film B');
300
+ SQL
301
+ path = write_langlinks("testwiki-20260101-langlinks.sql", sql)
302
+ check = import(path)[:sanity].first
303
+
304
+ expect(check[:match_rate]).to eq(0.0)
305
+ expect(check[:warning]).to be true
306
+ end
307
+ end
308
+ end