Skip to content

Commit d5f99e3

Browse files
committed
OPENNLP-1880: Address review: narrow the contract, validate at the boundary, pin WNDB rejections
- Narrow the LexicalKnowledgeBase javadoc so the interface stops prescribing what only an implementation can promise: lemma matching semantics and thread safety are now stated as implementation specific and documented where they hold, on InMemoryWordNetLexicon, which already carries @threadsafe and describes the folding it applies. - Reword the contains() javadoc to say plainly that the default implementation delegates to lookup(), instead of speculating about cheaper overrides. - Move the null-element checks in MorphyLemmatizer up to the public lemmatize() overloads, both the array form and the list form, so validation happens once at the boundary the caller sees; the private lemmasOf() no longer repeats them and now documents that its arguments are validated by the caller. - Reject a null argument in LemmaFolding.splitOnSpaces() rather than letting it fail later as a NullPointerException, and capitalize the fold() message so it matches the wording the other validators use. - Document the throws clauses that the explicit validation adds, on LemmaKey.of() and on splitOnSpaces(). - Extract the repeated WN-LMF attribute names into ID_ATTRIBUTE, PART_OF_SPEECH_ATTRIBUTE, REL_TYPE_ATTRIBUTE, and TARGET_ATTRIBUTE, and the shared error opening into MALFORMED_PREFIX, so the element handlers stop repeating string literals. - Extract the WNDB offset failure detail into MALFORMED_OFFSET, shared by the length check and the digit check. - Fold the duplicated WNDB message construction into malformedMessage(), so the tokenizer builds the text directly instead of constructing an InvalidFormatException only to read getMessage() back off it. - Drop the redundant fileName parameter from WndbReader.readAll(), which already names the full path it failed to open. - Reduce the visibility of the Parser helpers in WnLmfReader: malformed() and line() are now private instance methods like every other helper in that class. - Make MorphyLemmatizer.rulesFor() an instance method for the same reason, so the lemmatizer's private helpers are consistent. - Add the missing javadoc on the RELATION_NAMES and POINTER_SYMBOLS lookup tables and on both RawSynset holders, the last undocumented members in the readers. - Correct two stale comments: the build() comment now points at memberLemmas(), where the synset and member part-of-speech agreement is really checked, and the mutate() comment in the tests states the actual constraint, that an edit which changes a line's length is only safe when the reader is expected to fail on that line before it reads the ones after it. - Add a parameterized WNDB test pinning eight field-level rejections that had no coverage: the offset length and digit checks, the synset and index part of speech mismatches, the base-16 word count field, the minimum word count, the pointer pos, the gloss separator, and the syntactic marker. - Add pinning tests for the newly explicit validation: splitOnSpaces() on null, and the list lemmatize() overload with a null token and with a null tag. - Share the fixtures instead of duplicating them: WndbReaderTest now exposes DOG_ID, CANID_ID, and its fixtureDirectory(), WnLmfReaderTest exposes fixture(), and LexiconConcurrencyTest and WordNetUsageExampleTest use those instead of their own loader copies and hardcoded ids. - Document the package-private test fixture helpers and switch WordNetUsageExampleTest to static assertion imports, matching the other tests in the module.
1 parent 6c37523 commit d5f99e3

13 files changed

Lines changed: 163 additions & 82 deletions

File tree

opennlp-api/src/main/java/opennlp/tools/wordnet/LexicalKnowledgeBase.java

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,11 @@
2424
* identity is opaque and source-qualified (see {@link Synset#id()}). Lookups return their matches
2525
* in the source's sense order and never return {@code null}.
2626
*
27-
* <p>Lemma matching semantics are the implementation's concern. The reference implementations
28-
* match case-insensitively (case folding with the root locale) and treat the underscore some
29-
* formats store in multiword lemmas as a space; an implementation with different semantics must
30-
* document them. Returned {@link Synset#lemmas() lemmas} preserve the source's written forms,
31-
* with spaces in multiword lemmas.</p>
27+
* <p>How a queried lemma is matched against the source's written forms is implementation
28+
* specific and documented there. Returned {@link Synset#lemmas() lemmas} preserve the source's
29+
* written forms, with spaces in multiword lemmas.</p>
3230
*
33-
* <p>Implementations must be immutable and thread-safe after loading: one instance is meant to
34-
* be shared across an application's threads for concurrent lookups.</p>
31+
* <p>Thread safety is implementation specific.</p>
3532
*/
3633
public interface LexicalKnowledgeBase {
3734

@@ -74,9 +71,8 @@ default List<String> related(String synsetId, WordNetRelation relation) {
7471
}
7572

7673
/**
77-
* Tests whether the lexicon contains a lemma with a part of speech. This is the membership
78-
* check morphological rules validate their candidates against; implementations may override
79-
* it with a cheaper check than {@link #lookup(String, WordNetPOS)}.
74+
* Tests whether the lexicon contains a lemma with a part of speech. The default implementation
75+
* delegates to {@link #lookup(String, WordNetPOS)}.
8076
*
8177
* @param lemma The lemma to test. Must not be {@code null}.
8278
* @param pos The part of speech to test it as. Must not be {@code null}.

opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/InMemoryWordNetLexicon.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ record LemmaKey(String lemma, WordNetPOS pos) {
148148
* @param writtenForm The lemma as written in the source or query. Must not be {@code null}.
149149
* @param pos The part of speech. Must not be {@code null}.
150150
* @return The folded key.
151+
* @throws IllegalArgumentException Thrown if {@code writtenForm} is {@code null}.
151152
*/
152153
static LemmaKey of(String writtenForm, WordNetPOS pos) {
153154
return new LemmaKey(LemmaFolding.fold(writtenForm), pos);

opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/LemmaFolding.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ private LemmaFolding() {
4141
*/
4242
static String fold(String writtenForm) {
4343
if (writtenForm == null) {
44-
throw new IllegalArgumentException("writtenForm must not be null");
44+
throw new IllegalArgumentException("WrittenForm must not be null");
4545
}
4646
return writtenForm.replace('_', ' ').toLowerCase(Locale.ROOT);
4747
}
@@ -51,8 +51,12 @@ static String fold(String writtenForm) {
5151
*
5252
* @param value The field list. Must not be {@code null}.
5353
* @return The non-empty fields in order, never {@code null}.
54+
* @throws IllegalArgumentException Thrown if {@code value} is {@code null}.
5455
*/
5556
static List<String> splitOnSpaces(String value) {
57+
if (value == null) {
58+
throw new IllegalArgumentException("Value must not be null");
59+
}
5660
final List<String> parts = new ArrayList<>(4);
5761
int start = 0;
5862
while (start < value.length()) {

opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/MorphyLemmatizer.java

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ public String[] lemmatize(String[] toks, String[] tags) {
112112
}
113113
final String[] lemmas = new String[toks.length];
114114
for (int i = 0; i < toks.length; i++) {
115+
if (toks[i] == null) {
116+
throw new IllegalArgumentException("Toks must not contain a null element");
117+
}
118+
if (tags[i] == null) {
119+
throw new IllegalArgumentException("Tags must not contain a null element");
120+
}
115121
final List<String> candidates = lemmasOf(toks[i], tags[i]);
116122
lemmas[i] = candidates.isEmpty() ? UNKNOWN_LEMMA : candidates.get(0);
117123
}
@@ -138,6 +144,12 @@ public List<List<String>> lemmatize(List<String> toks, List<String> tags) {
138144
}
139145
final List<List<String>> lemmas = new ArrayList<>(toks.size());
140146
for (int i = 0; i < toks.size(); i++) {
147+
if (toks.get(i) == null) {
148+
throw new IllegalArgumentException("Toks must not contain a null element");
149+
}
150+
if (tags.get(i) == null) {
151+
throw new IllegalArgumentException("Tags must not contain a null element");
152+
}
141153
final List<String> candidates = lemmasOf(toks.get(i), tags.get(i));
142154
lemmas.add(candidates.isEmpty() ? List.of(UNKNOWN_LEMMA) : candidates);
143155
}
@@ -147,15 +159,12 @@ public List<List<String>> lemmatize(List<String> toks, List<String> tags) {
147159
/**
148160
* Finds all lemmas of one token, most preferred first.
149161
*
150-
* @param token The token to lemmatize.
151-
* @param tag The part-of-speech tag.
162+
* @param token The token to lemmatize. Validated at the public boundary.
163+
* @param tag The part-of-speech tag. Validated at the public boundary.
152164
* @return The candidate lemmas, empty when the word is unknown or the tag maps to no part of
153165
* speech.
154166
*/
155167
private List<String> lemmasOf(String token, String tag) {
156-
if (token == null || tag == null) {
157-
throw new IllegalArgumentException("Tokens and tags must not contain null elements");
158-
}
159168
final WordNetPOS pos = posFromTag(tag);
160169
if (pos == null) {
161170
return List.of();
@@ -188,7 +197,7 @@ private List<String> lemmasOf(String token, String tag) {
188197
* @param pos The part of speech.
189198
* @return The suffix-substitution rules, empty for adverbs.
190199
*/
191-
private static String[][] rulesFor(WordNetPOS pos) {
200+
private String[][] rulesFor(WordNetPOS pos) {
192201
return switch (pos) {
193202
case NOUN -> NOUN_RULES;
194203
case VERB -> VERB_RULES;

opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WnLmfReader.java

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
*/
6767
public final class WnLmfReader {
6868

69+
/** The WN-LMF relation names this reader accepts, mapped to the contract relations. */
6970
private static final Map<String, WordNetRelation> RELATION_NAMES = relationNames();
7071

7172
/** The format's escape-hatch relation type; carries no type the contract can express. */
@@ -80,6 +81,21 @@ public final class WnLmfReader {
8081
/** The element declaring a synset; opened and closed by the same handlers. */
8182
private static final String SYNSET_ELEMENT = "Synset";
8283

84+
/** The identifier attribute shared by entries, senses, and synsets. */
85+
private static final String ID_ATTRIBUTE = "id";
86+
87+
/** The part-of-speech attribute shared by lemmas and synsets. */
88+
private static final String PART_OF_SPEECH_ATTRIBUTE = "partOfSpeech";
89+
90+
/** The relation-type attribute shared by sense and synset relations. */
91+
private static final String REL_TYPE_ATTRIBUTE = "relType";
92+
93+
/** The relation-target attribute shared by sense and synset relations. */
94+
private static final String TARGET_ATTRIBUTE = "target";
95+
96+
/** The opening of every malformed-document message, before the resource name. */
97+
private static final String MALFORMED_PREFIX = "Malformed WN-LMF document ";
98+
8399
/** Not instantiable. */
84100
private WnLmfReader() {
85101
}
@@ -227,7 +243,7 @@ private void startElement(XMLStreamReader reader)
227243
final String name = reader.getLocalName();
228244
switch (name) {
229245
case LEXICAL_ENTRY_ELEMENT -> {
230-
currentEntryId = requireAttribute(reader, "id");
246+
currentEntryId = requireAttribute(reader, ID_ATTRIBUTE);
231247
if (!entryIds.add(currentEntryId)) {
232248
throw malformed(reader.getLocation(),
233249
"Duplicate lexical entry id " + currentEntryId, null);
@@ -240,7 +256,7 @@ private void startElement(XMLStreamReader reader)
240256
throw malformed(reader.getLocation(), "Lemma outside a LexicalEntry", null);
241257
}
242258
currentEntryLemma = requireAttribute(reader, "writtenForm");
243-
currentEntryPos = parsePos(requireAttribute(reader, "partOfSpeech"),
259+
currentEntryPos = parsePos(requireAttribute(reader, PART_OF_SPEECH_ATTRIBUTE),
244260
reader.getLocation());
245261
lemmaByEntryId.put(currentEntryId, currentEntryLemma);
246262
posByEntryId.put(currentEntryId, currentEntryPos);
@@ -250,7 +266,7 @@ private void startElement(XMLStreamReader reader)
250266
throw malformed(reader.getLocation(),
251267
"Sense before its entry's Lemma in LexicalEntry " + currentEntryId, null);
252268
}
253-
currentSenseId = requireAttribute(reader, "id");
269+
currentSenseId = requireAttribute(reader, ID_ATTRIBUTE);
254270
final String synsetId = requireAttribute(reader, "synset");
255271
if (synsetBySenseId.putIfAbsent(currentSenseId, synsetId) != null) {
256272
throw malformed(reader.getLocation(), "Duplicate sense id " + currentSenseId, null);
@@ -269,12 +285,12 @@ private void startElement(XMLStreamReader reader)
269285
throw malformed(reader.getLocation(), "SenseRelation outside a Sense", null);
270286
}
271287
senseRelations.add(new RawSenseRelation(currentSenseId,
272-
requireAttribute(reader, "relType"), requireAttribute(reader, "target"),
273-
line(reader.getLocation())));
288+
requireAttribute(reader, REL_TYPE_ATTRIBUTE),
289+
requireAttribute(reader, TARGET_ATTRIBUTE), line(reader.getLocation())));
274290
}
275291
case SYNSET_ELEMENT -> {
276-
final String id = requireAttribute(reader, "id");
277-
final WordNetPOS pos = parsePos(requireAttribute(reader, "partOfSpeech"),
292+
final String id = requireAttribute(reader, ID_ATTRIBUTE);
293+
final WordNetPOS pos = parsePos(requireAttribute(reader, PART_OF_SPEECH_ATTRIBUTE),
278294
reader.getLocation());
279295
currentSynset = new RawSynset(id, pos, reader.getAttributeValue(null, "members"),
280296
line(reader.getLocation()));
@@ -291,8 +307,8 @@ private void startElement(XMLStreamReader reader)
291307
if (currentSynset == null) {
292308
throw malformed(reader.getLocation(), "SynsetRelation outside a Synset", null);
293309
}
294-
final String relType = requireAttribute(reader, "relType");
295-
final String target = requireAttribute(reader, "target");
310+
final String relType = requireAttribute(reader, REL_TYPE_ATTRIBUTE);
311+
final String target = requireAttribute(reader, TARGET_ATTRIBUTE);
296312
// The escape-hatch type is a documented skip, not a rejection.
297313
if (!OTHER_RELATION.equals(relType)) {
298314
currentSynset.relations.add(
@@ -335,7 +351,8 @@ private void endElement(String name) {
335351
* target, or a synset has no members.
336352
*/
337353
LexicalKnowledgeBase build() throws InvalidFormatException {
338-
// Every sense must point to a declared synset, with a consistent part of speech.
354+
// Every sense must point to a declared synset; part-of-speech consistency between a
355+
// synset and its member entries is checked in memberLemmas.
339356
for (final Map.Entry<String, String> sense : synsetBySenseId.entrySet()) {
340357
final RawSynset target = rawSynsets.get(sense.getValue());
341358
if (target == null) {
@@ -507,10 +524,10 @@ private String requireAttribute(XMLStreamReader reader, String attribute)
507524
* @param cause The underlying cause, or {@code null}.
508525
* @return The exception to throw.
509526
*/
510-
InvalidFormatException malformed(Location location, String message, Throwable cause) {
527+
private InvalidFormatException malformed(Location location, String message, Throwable cause) {
511528
final int line = line(location);
512-
final String prefix = line < 0 ? "Malformed WN-LMF document " + resourceName + ": "
513-
: "Malformed WN-LMF document " + resourceName + " at line " + line + ": ";
529+
final String prefix = line < 0 ? MALFORMED_PREFIX + resourceName + ": "
530+
: MALFORMED_PREFIX + resourceName + " at line " + line + ": ";
514531
return cause == null ? new InvalidFormatException(prefix + message)
515532
: new InvalidFormatException(prefix + message, cause);
516533
}
@@ -521,11 +538,12 @@ InvalidFormatException malformed(Location location, String message, Throwable ca
521538
* @param location The location, or {@code null}.
522539
* @return The line number, or {@code -1} when unknown.
523540
*/
524-
private static int line(Location location) {
541+
private int line(Location location) {
525542
return location == null ? -1 : location.getLineNumber();
526543
}
527544
}
528545

546+
/** A parsed synset, kept until its members and relation targets can be resolved. */
529547
private static final class RawSynset {
530548
private final String id;
531549
private final WordNetPOS pos;

opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WndbReader.java

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,15 @@
5757
*/
5858
public final class WndbReader {
5959

60+
/** The WNDB pointer symbols this reader accepts, mapped to the contract relations. */
6061
private static final Map<String, WordNetRelation> POINTER_SYMBOLS = pointerSymbols();
6162

6263
/** The prefix of every synset id this reader mints. */
6364
private static final String SYNSET_ID_PREFIX = "wndb-";
6465

66+
/** The failure detail for a synset offset field that is not exactly 8 digits. */
67+
private static final String MALFORMED_OFFSET = "Synset offset must be 8 digits, got: ";
68+
6569
/** Not instantiable. */
6670
private WndbReader() {
6771
}
@@ -146,7 +150,7 @@ private enum FilePos {
146150
private static void parseDataFile(Path directory, FilePos filePos,
147151
Map<String, RawSynset> rawSynsets) throws IOException {
148152
final String fileName = "data." + filePos.suffix;
149-
final byte[] bytes = readAll(directory.resolve(fileName), fileName);
153+
final byte[] bytes = readAll(directory.resolve(fileName));
150154
int lineStart = 0;
151155
int lineNumber = 0;
152156
while (lineStart < bytes.length) {
@@ -281,7 +285,7 @@ private static void parseIndexFile(Path directory, FilePos filePos,
281285
Map<InMemoryWordNetLexicon.LemmaKey, List<String>> senses)
282286
throws IOException {
283287
final String fileName = "index." + filePos.suffix;
284-
final byte[] bytes = readAll(directory.resolve(fileName), fileName);
288+
final byte[] bytes = readAll(directory.resolve(fileName));
285289
final String content = new String(bytes, StandardCharsets.ISO_8859_1);
286290
int lineNumber = 0;
287291
int lineStart = 0;
@@ -399,13 +403,13 @@ private static String cleanLemma(String word, String fileName, int lineNumber)
399403
*/
400404
private static int parseOffset(String offset, Tokenizer tokens) throws InvalidFormatException {
401405
if (offset.length() != 8) {
402-
throw tokens.malformedToken("Synset offset must be 8 digits, got: " + offset);
406+
throw tokens.malformedToken(MALFORMED_OFFSET + offset);
403407
}
404408
int value = 0;
405409
for (int i = 0; i < 8; i++) {
406410
final char c = offset.charAt(i);
407411
if (c < '0' || c > '9') {
408-
throw tokens.malformedToken("Synset offset must be 8 digits, got: " + offset);
412+
throw tokens.malformedToken(MALFORMED_OFFSET + offset);
409413
}
410414
value = value * 10 + (c - '0');
411415
}
@@ -433,13 +437,12 @@ private static char posChar(String pos, Tokenizer tokens) throws InvalidFormatEx
433437
/**
434438
* Reads a required database file in full.
435439
*
436-
* @param file The file path.
437-
* @param fileName The file name, for error reporting.
440+
* @param file The file path.
438441
* @return The file bytes.
439442
* @throws InvalidFormatException Thrown if the file is missing.
440443
* @throws IOException Thrown if reading fails.
441444
*/
442-
private static byte[] readAll(Path file, String fileName) throws IOException {
445+
private static byte[] readAll(Path file) throws IOException {
443446
if (!Files.isRegularFile(file)) {
444447
throw new InvalidFormatException("Missing WNDB database file: " + file);
445448
}
@@ -456,8 +459,19 @@ private static byte[] readAll(Path file, String fileName) throws IOException {
456459
*/
457460
private static InvalidFormatException malformed(String fileName, int lineNumber,
458461
String message) {
459-
return new InvalidFormatException(
460-
"Malformed WNDB file " + fileName + " at line " + lineNumber + ": " + message);
462+
return new InvalidFormatException(malformedMessage(fileName, lineNumber, message));
463+
}
464+
465+
/**
466+
* Builds the malformed-file message naming the file and line.
467+
*
468+
* @param fileName The file name.
469+
* @param lineNumber The 1-based line number.
470+
* @param message The failure detail.
471+
* @return The message text.
472+
*/
473+
private static String malformedMessage(String fileName, int lineNumber, String message) {
474+
return "Malformed WNDB file " + fileName + " at line " + lineNumber + ": " + message;
461475
}
462476

463477
/** A cursor over one line's space-separated fields. */
@@ -515,8 +529,8 @@ int nextInt(String field, int radix) throws InvalidFormatException {
515529
try {
516530
return Integer.parseInt(token, radix);
517531
} catch (NumberFormatException e) {
518-
throw new InvalidFormatException(malformed(fileName, lineNumber,
519-
"Field " + field + " is not a base-" + radix + " integer: " + token).getMessage(), e);
532+
throw new InvalidFormatException(malformedMessage(fileName, lineNumber,
533+
"Field " + field + " is not a base-" + radix + " integer: " + token), e);
520534
}
521535
}
522536

@@ -557,6 +571,7 @@ InvalidFormatException malformedToken(String message) {
557571
private record RawPointer(WordNetRelation relation, String targetId, int lineNumber) {
558572
}
559573

574+
/** A parsed data-file synset, kept until its pointer targets can be resolved. */
560575
private static final class RawSynset {
561576
private final String id;
562577
private final WordNetPOS pos;

opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/LemmaFoldingTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,9 @@ void testLemmaKeyAndExceptionLookupAgreeOnTheFold() {
6363
void testFoldRejectsNull() {
6464
assertThrows(IllegalArgumentException.class, () -> LemmaFolding.fold(null));
6565
}
66+
67+
@Test
68+
void testSplitOnSpacesRejectsNull() {
69+
assertThrows(IllegalArgumentException.class, () -> LemmaFolding.splitOnSpaces(null));
70+
}
6671
}

opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/LexiconConcurrencyTest.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,14 @@ void testConcurrentLookupsSeeConsistentResults() throws InterruptedException {
7171
}
7272

7373
private static void verifyOnce(LexicalKnowledgeBase lexicon, Queue<String> problems) {
74-
if (!"wndb-00001075-n".equals(lexicon.lookup("dog", WordNetPOS.NOUN).get(0).id())) {
74+
if (!WndbReaderTest.DOG_ID.equals(lexicon.lookup("dog", WordNetPOS.NOUN).get(0).id())) {
7575
problems.add("Wrong dog lookup");
7676
}
7777
if (lexicon.lookup("run", WordNetPOS.NOUN).size() != 2) {
7878
problems.add("Wrong run sense count");
7979
}
80-
if (!List.of("wndb-00001160-n")
81-
.equals(lexicon.related("wndb-00001075-n", WordNetRelation.HYPERNYM))) {
80+
if (!List.of(WndbReaderTest.CANID_ID)
81+
.equals(lexicon.related(WndbReaderTest.DOG_ID, WordNetRelation.HYPERNYM))) {
8282
problems.add("Wrong dog hypernym");
8383
}
8484
if (lexicon.contains("zebra", WordNetPOS.NOUN)) {

opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/MorphyExceptionsTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@
3535

3636
public class MorphyExceptionsTest {
3737

38+
/**
39+
* Loads the exception lists from the miniature WNDB fixture directory.
40+
*
41+
* @return The loaded fixture exception lists.
42+
*/
3843
static MorphyExceptions fixture() {
3944
try {
4045
return MorphyExceptions.load(WndbReaderTest.fixtureDirectory());

0 commit comments

Comments
 (0)