First INIT
This commit is contained in:
117
src/main/java/de/dogfire/dqfl/Dqfl.java
Normal file
117
src/main/java/de/dogfire/dqfl/Dqfl.java
Normal file
@@ -0,0 +1,117 @@
|
||||
|
||||
package de.dogfire.dqfl;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import de.dogfire.dqfl.error.DqflParseException;
|
||||
import de.dogfire.dqfl.error.DqflValidationResult;
|
||||
import de.dogfire.dqfl.model.DqflDocument;
|
||||
import de.dogfire.dqfl.parser.DqflParser;
|
||||
import de.dogfire.dqfl.validator.DqflValidator;
|
||||
|
||||
/**
|
||||
* Zentrale Fassade der DQFL V1 Library.
|
||||
* Bietet bequeme Methoden zum Laden, Parsen und Validieren von DQFL-Dokumenten.
|
||||
*
|
||||
* <pre>
|
||||
* // Beispiel: Datei laden, parsen und validieren
|
||||
* DqflDocument doc = Dqfl.parseFile(path);
|
||||
* DqflValidationResult result = Dqfl.validate(doc);
|
||||
* if (!result.isValid()) { ... }
|
||||
*
|
||||
* // Beispiel: String direkt parsen
|
||||
* DqflDocument doc = Dqfl.parse(dqflString);
|
||||
* </pre>
|
||||
*/
|
||||
public final class Dqfl
|
||||
{
|
||||
private Dqfl()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst einen DQFL-String und gibt das Dokumentmodell zurück.
|
||||
*
|
||||
* @throws DqflParseException bei Syntaxfehlern
|
||||
*/
|
||||
public static DqflDocument parse(final String input) throws DqflParseException
|
||||
{
|
||||
return new DqflParser().parse(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest und parst eine .dqfl-Datei.
|
||||
*
|
||||
* @throws DqflParseException bei Syntaxfehlern
|
||||
* @throws IOException bei Dateizugriffsfehlern
|
||||
*/
|
||||
public static DqflDocument parseFile(final Path path) throws DqflParseException, IOException
|
||||
{
|
||||
final String content = Files.readString(path, StandardCharsets.UTF_8);
|
||||
return Dqfl.parse(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest und parst aus einem InputStream (z.B. Classpath-Resource).
|
||||
*
|
||||
* @throws DqflParseException bei Syntaxfehlern
|
||||
* @throws IOException bei Lesefehlern
|
||||
*/
|
||||
public static DqflDocument parseStream(final InputStream stream) throws DqflParseException, IOException
|
||||
{
|
||||
final String content = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
return Dqfl.parse(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert ein geparstes DqflDocument gegen alle V1-Fachregeln.
|
||||
*/
|
||||
public static DqflValidationResult validate(final DqflDocument document)
|
||||
{
|
||||
return new DqflValidator().validate(document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst und validiert in einem Schritt.
|
||||
* Gibt das Dokument nur zurück wenn die Validierung keine Fehler enthält.
|
||||
*
|
||||
* @throws DqflParseException bei Syntaxfehlern
|
||||
* @throws DqflValidationException wenn die Validierung fehlschlägt
|
||||
*/
|
||||
public static DqflDocument parseAndValidate(final String input)
|
||||
throws DqflParseException, DqflValidationException
|
||||
{
|
||||
final DqflDocument doc = Dqfl.parse(input);
|
||||
final DqflValidationResult result = Dqfl.validate(doc);
|
||||
|
||||
if(!result.isValid())
|
||||
{
|
||||
throw new DqflValidationException(result);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception die geworfen wird wenn ein Dokument die Validierung nicht besteht.
|
||||
*/
|
||||
public static class DqflValidationException extends Exception
|
||||
{
|
||||
private final DqflValidationResult result;
|
||||
|
||||
public DqflValidationException(final DqflValidationResult result)
|
||||
{
|
||||
super("DQFL validation failed: " + result.getErrorsOnly().size() + " error(s)");
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
public DqflValidationResult getResult()
|
||||
{
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/main/java/de/dogfire/dqfl/error/DqflError.java
Normal file
79
src/main/java/de/dogfire/dqfl/error/DqflError.java
Normal file
@@ -0,0 +1,79 @@
|
||||
|
||||
package de.dogfire.dqfl.error;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Einzelner Fehler aus Parsing oder Validierung.
|
||||
*/
|
||||
public final class DqflError
|
||||
{
|
||||
public enum Severity
|
||||
{
|
||||
ERROR,
|
||||
WARNING
|
||||
}
|
||||
|
||||
private final Severity severity;
|
||||
private final int line;
|
||||
private final String message;
|
||||
|
||||
public DqflError(final Severity severity, final int line, final String message)
|
||||
{
|
||||
this.severity = Objects.requireNonNull(severity);
|
||||
this.line = line;
|
||||
this.message = Objects.requireNonNull(message);
|
||||
}
|
||||
|
||||
public static DqflError error(final int line, final String message)
|
||||
{
|
||||
return new DqflError(Severity.ERROR, line, message);
|
||||
}
|
||||
|
||||
public static DqflError warning(final int line, final String message)
|
||||
{
|
||||
return new DqflError(Severity.WARNING, line, message);
|
||||
}
|
||||
|
||||
/** Fehler ohne Zeilenbezug (z.B. Validierung auf Dokumentebene). */
|
||||
public static DqflError error(final String message)
|
||||
{
|
||||
return new DqflError(Severity.ERROR, -1, message);
|
||||
}
|
||||
|
||||
public static DqflError warning(final String message)
|
||||
{
|
||||
return new DqflError(Severity.WARNING, -1, message);
|
||||
}
|
||||
|
||||
public Severity getSeverity()
|
||||
{
|
||||
return this.severity;
|
||||
}
|
||||
|
||||
public int getLine()
|
||||
{
|
||||
return this.line;
|
||||
}
|
||||
|
||||
public String getMessage()
|
||||
{
|
||||
return this.message;
|
||||
}
|
||||
|
||||
public boolean isError()
|
||||
{
|
||||
return this.severity == Severity.ERROR;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
final String prefix = this.severity == Severity.ERROR ? "ERROR" : "WARN";
|
||||
if(this.line > 0)
|
||||
{
|
||||
return "[" + prefix + " line " + this.line + "] " + this.message;
|
||||
}
|
||||
return "[" + prefix + "] " + this.message;
|
||||
}
|
||||
}
|
||||
42
src/main/java/de/dogfire/dqfl/error/DqflParseException.java
Normal file
42
src/main/java/de/dogfire/dqfl/error/DqflParseException.java
Normal file
@@ -0,0 +1,42 @@
|
||||
|
||||
package de.dogfire.dqfl.error;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Exception die geworfen wird wenn das Parsen eines DQFL-Dokuments fehlschlägt.
|
||||
* Enthält alle gesammelten Fehler.
|
||||
*/
|
||||
public class DqflParseException extends Exception
|
||||
{
|
||||
private final List<DqflError> errors;
|
||||
|
||||
public DqflParseException(final String message, final List<DqflError> errors)
|
||||
{
|
||||
super(message);
|
||||
this.errors = Collections.unmodifiableList(errors);
|
||||
}
|
||||
|
||||
public DqflParseException(final String message)
|
||||
{
|
||||
super(message);
|
||||
this.errors = List.of();
|
||||
}
|
||||
|
||||
public List<DqflError> getErrors()
|
||||
{
|
||||
return this.errors;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
final StringBuilder sb = new StringBuilder(this.getMessage());
|
||||
for(final DqflError error : this.errors)
|
||||
{
|
||||
sb.append("\n ").append(error);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
|
||||
package de.dogfire.dqfl.error;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Ergebnis einer DQFL-Validierung.
|
||||
*/
|
||||
public final class DqflValidationResult
|
||||
{
|
||||
private final List<DqflError> errors;
|
||||
|
||||
public DqflValidationResult(final List<DqflError> errors)
|
||||
{
|
||||
this.errors = Collections.unmodifiableList(new ArrayList<>(errors));
|
||||
}
|
||||
|
||||
public boolean isValid()
|
||||
{
|
||||
return this.errors.stream().noneMatch(DqflError::isError);
|
||||
}
|
||||
|
||||
public boolean hasWarnings()
|
||||
{
|
||||
return this.errors.stream().anyMatch(e -> !e.isError());
|
||||
}
|
||||
|
||||
public List<DqflError> getErrors()
|
||||
{
|
||||
return this.errors;
|
||||
}
|
||||
|
||||
public List<DqflError> getErrorsOnly()
|
||||
{
|
||||
return this.errors.stream().filter(DqflError::isError).toList();
|
||||
}
|
||||
|
||||
public List<DqflError> getWarningsOnly()
|
||||
{
|
||||
return this.errors.stream().filter(e -> !e.isError()).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
if(this.isValid() && !this.hasWarnings())
|
||||
{
|
||||
return "Validation OK";
|
||||
}
|
||||
final StringBuilder sb = new StringBuilder("Validation ");
|
||||
sb.append(this.isValid() ? "OK (with warnings)" : "FAILED");
|
||||
for(final DqflError error : this.errors)
|
||||
{
|
||||
sb.append("\n ").append(error);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
130
src/main/java/de/dogfire/dqfl/model/DqflCategory.java
Normal file
130
src/main/java/de/dogfire/dqfl/model/DqflCategory.java
Normal file
@@ -0,0 +1,130 @@
|
||||
|
||||
package de.dogfire.dqfl.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Repräsentiert einen CATEGORY-Block innerhalb einer ROUND.
|
||||
*/
|
||||
public final class DqflCategory
|
||||
{
|
||||
private final String alias;
|
||||
private final String title;
|
||||
private final String usePool;
|
||||
private final LayoutType layout;
|
||||
private final GridSize size;
|
||||
private final List<DqflPhase> phases;
|
||||
|
||||
private DqflCategory(final Builder builder)
|
||||
{
|
||||
this.alias = Objects.requireNonNull(builder.alias, "alias must not be null");
|
||||
this.title = builder.title;
|
||||
this.usePool = builder.usePool;
|
||||
this.layout = builder.layout;
|
||||
this.size = builder.size;
|
||||
this.phases = Collections.unmodifiableList(new ArrayList<>(builder.phases));
|
||||
}
|
||||
|
||||
public String getAlias()
|
||||
{
|
||||
return this.alias;
|
||||
}
|
||||
|
||||
/** Kann null sein — TITLE ist optional. */
|
||||
public String getTitle()
|
||||
{
|
||||
return this.title;
|
||||
}
|
||||
|
||||
public String getUsePool()
|
||||
{
|
||||
return this.usePool;
|
||||
}
|
||||
|
||||
public LayoutType getLayout()
|
||||
{
|
||||
return this.layout;
|
||||
}
|
||||
|
||||
/** Kann null sein — nur gültig zusammen mit LAYOUT. */
|
||||
public GridSize getSize()
|
||||
{
|
||||
return this.size;
|
||||
}
|
||||
|
||||
public List<DqflPhase> getPhases()
|
||||
{
|
||||
return this.phases;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return "CATEGORY " + this.alias
|
||||
+ " [title=" + this.title
|
||||
+ ", pool=" + this.usePool
|
||||
+ ", phases=" + this.phases.size() + "]";
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Builder
|
||||
// ================================================================
|
||||
|
||||
public static Builder builder(final String alias)
|
||||
{
|
||||
return new Builder(alias);
|
||||
}
|
||||
|
||||
public static final class Builder
|
||||
{
|
||||
private final String alias;
|
||||
private String title;
|
||||
private String usePool;
|
||||
private LayoutType layout;
|
||||
private GridSize size;
|
||||
private final List<DqflPhase> phases = new ArrayList<>();
|
||||
|
||||
private Builder(final String alias)
|
||||
{
|
||||
this.alias = alias;
|
||||
}
|
||||
|
||||
public Builder title(final String title)
|
||||
{
|
||||
this.title = title;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder usePool(final String usePool)
|
||||
{
|
||||
this.usePool = usePool;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder layout(final LayoutType layout)
|
||||
{
|
||||
this.layout = layout;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder size(final GridSize size)
|
||||
{
|
||||
this.size = size;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addPhase(final DqflPhase phase)
|
||||
{
|
||||
this.phases.add(Objects.requireNonNull(phase));
|
||||
return this;
|
||||
}
|
||||
|
||||
public DqflCategory build()
|
||||
{
|
||||
return new DqflCategory(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
162
src/main/java/de/dogfire/dqfl/model/DqflDocument.java
Normal file
162
src/main/java/de/dogfire/dqfl/model/DqflDocument.java
Normal file
@@ -0,0 +1,162 @@
|
||||
|
||||
package de.dogfire.dqfl.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Repräsentiert ein vollständig geparstes DQFL-Dokument.
|
||||
* Dies ist der Root-Knoten des AST.
|
||||
*/
|
||||
public final class DqflDocument
|
||||
{
|
||||
private final String dqflVersion;
|
||||
private final String flowName;
|
||||
private final Integer scriptRevision;
|
||||
private final Map<String, DqflSource> sources;
|
||||
private final Map<String, DqflPool> pools;
|
||||
private final List<DqflRound> rounds;
|
||||
|
||||
private DqflDocument(final Builder builder)
|
||||
{
|
||||
this.dqflVersion = Objects.requireNonNull(builder.dqflVersion, "dqflVersion must not be null");
|
||||
this.flowName = Objects.requireNonNull(builder.flowName, "flowName must not be null");
|
||||
this.scriptRevision = builder.scriptRevision;
|
||||
this.sources = Collections.unmodifiableMap(new LinkedHashMap<>(builder.sources));
|
||||
this.pools = Collections.unmodifiableMap(new LinkedHashMap<>(builder.pools));
|
||||
this.rounds = Collections.unmodifiableList(new ArrayList<>(builder.rounds));
|
||||
}
|
||||
|
||||
public String getDqflVersion()
|
||||
{
|
||||
return this.dqflVersion;
|
||||
}
|
||||
|
||||
public String getFlowName()
|
||||
{
|
||||
return this.flowName;
|
||||
}
|
||||
|
||||
/** Kann null sein — SCRIPT_REVISION ist optional. */
|
||||
public Integer getScriptRevision()
|
||||
{
|
||||
return this.scriptRevision;
|
||||
}
|
||||
|
||||
public Map<String, DqflSource> getSources()
|
||||
{
|
||||
return this.sources;
|
||||
}
|
||||
|
||||
public DqflSource getSource(final String alias)
|
||||
{
|
||||
return this.sources.get(alias);
|
||||
}
|
||||
|
||||
public Map<String, DqflPool> getPools()
|
||||
{
|
||||
return this.pools;
|
||||
}
|
||||
|
||||
public DqflPool getPool(final String alias)
|
||||
{
|
||||
return this.pools.get(alias);
|
||||
}
|
||||
|
||||
public List<DqflRound> getRounds()
|
||||
{
|
||||
return this.rounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt eine flache Liste aller Kategorien über alle Runden zurück,
|
||||
* in Dokumentreihenfolge.
|
||||
*/
|
||||
public List<DqflCategory> getAllCategoriesFlat()
|
||||
{
|
||||
final List<DqflCategory> all = new ArrayList<>();
|
||||
for(final DqflRound round : this.rounds)
|
||||
{
|
||||
all.addAll(round.getCategories());
|
||||
}
|
||||
return Collections.unmodifiableList(all);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return "DqflDocument [version=" + this.dqflVersion
|
||||
+ ", flow=" + this.flowName
|
||||
+ ", sources=" + this.sources.size()
|
||||
+ ", pools=" + this.pools.size()
|
||||
+ ", rounds=" + this.rounds.size() + "]";
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Builder
|
||||
// ================================================================
|
||||
|
||||
public static Builder builder()
|
||||
{
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
public static final class Builder
|
||||
{
|
||||
private String dqflVersion;
|
||||
private String flowName;
|
||||
private Integer scriptRevision;
|
||||
private final Map<String, DqflSource> sources = new LinkedHashMap<>();
|
||||
private final Map<String, DqflPool> pools = new LinkedHashMap<>();
|
||||
private final List<DqflRound> rounds = new ArrayList<>();
|
||||
|
||||
private Builder()
|
||||
{
|
||||
}
|
||||
|
||||
public Builder dqflVersion(final String dqflVersion)
|
||||
{
|
||||
this.dqflVersion = dqflVersion;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder flowName(final String flowName)
|
||||
{
|
||||
this.flowName = flowName;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder scriptRevision(final Integer scriptRevision)
|
||||
{
|
||||
this.scriptRevision = scriptRevision;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addSource(final DqflSource source)
|
||||
{
|
||||
this.sources.put(source.getAlias(), source);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addPool(final DqflPool pool)
|
||||
{
|
||||
this.pools.put(pool.getAlias(), pool);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addRound(final DqflRound round)
|
||||
{
|
||||
this.rounds.add(Objects.requireNonNull(round));
|
||||
return this;
|
||||
}
|
||||
|
||||
public DqflDocument build()
|
||||
{
|
||||
return new DqflDocument(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
167
src/main/java/de/dogfire/dqfl/model/DqflPhase.java
Normal file
167
src/main/java/de/dogfire/dqfl/model/DqflPhase.java
Normal file
@@ -0,0 +1,167 @@
|
||||
|
||||
package de.dogfire.dqfl.model;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Repräsentiert einen PHASE-Block innerhalb einer CATEGORY.
|
||||
*/
|
||||
public final class DqflPhase
|
||||
{
|
||||
private final PhaseType phaseType;
|
||||
private final QuestionSetType questionSet;
|
||||
private final Integer cycles;
|
||||
private final Integer turnsPerCycle;
|
||||
private final TurnOrderType turnOrder;
|
||||
private final PickModeType pickMode;
|
||||
private final TeamModeType teamMode;
|
||||
private final TeamAssignmentType teamAssignment;
|
||||
private final ScoringProfile scoring;
|
||||
|
||||
private DqflPhase(final Builder builder)
|
||||
{
|
||||
this.phaseType = Objects.requireNonNull(builder.phaseType, "phaseType must not be null");
|
||||
this.questionSet = builder.questionSet;
|
||||
this.cycles = builder.cycles;
|
||||
this.turnsPerCycle = builder.turnsPerCycle;
|
||||
this.turnOrder = builder.turnOrder;
|
||||
this.pickMode = builder.pickMode;
|
||||
this.teamMode = builder.teamMode;
|
||||
this.teamAssignment = builder.teamAssignment;
|
||||
this.scoring = builder.scoring;
|
||||
}
|
||||
|
||||
public PhaseType getPhaseType()
|
||||
{
|
||||
return this.phaseType;
|
||||
}
|
||||
|
||||
public QuestionSetType getQuestionSet()
|
||||
{
|
||||
return this.questionSet;
|
||||
}
|
||||
|
||||
public Integer getCycles()
|
||||
{
|
||||
return this.cycles;
|
||||
}
|
||||
|
||||
public Integer getTurnsPerCycle()
|
||||
{
|
||||
return this.turnsPerCycle;
|
||||
}
|
||||
|
||||
public TurnOrderType getTurnOrder()
|
||||
{
|
||||
return this.turnOrder;
|
||||
}
|
||||
|
||||
public PickModeType getPickMode()
|
||||
{
|
||||
return this.pickMode;
|
||||
}
|
||||
|
||||
public TeamModeType getTeamMode()
|
||||
{
|
||||
return this.teamMode;
|
||||
}
|
||||
|
||||
public TeamAssignmentType getTeamAssignment()
|
||||
{
|
||||
return this.teamAssignment;
|
||||
}
|
||||
|
||||
public ScoringProfile getScoring()
|
||||
{
|
||||
return this.scoring;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return "PHASE " + this.phaseType
|
||||
+ " [questionSet=" + this.questionSet
|
||||
+ ", teamMode=" + this.teamMode
|
||||
+ ", scoring=" + this.scoring + "]";
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Builder
|
||||
// ================================================================
|
||||
|
||||
public static Builder builder(final PhaseType phaseType)
|
||||
{
|
||||
return new Builder(phaseType);
|
||||
}
|
||||
|
||||
public static final class Builder
|
||||
{
|
||||
private final PhaseType phaseType;
|
||||
private QuestionSetType questionSet;
|
||||
private Integer cycles;
|
||||
private Integer turnsPerCycle;
|
||||
private TurnOrderType turnOrder;
|
||||
private PickModeType pickMode;
|
||||
private TeamModeType teamMode;
|
||||
private TeamAssignmentType teamAssignment;
|
||||
private ScoringProfile scoring;
|
||||
|
||||
private Builder(final PhaseType phaseType)
|
||||
{
|
||||
this.phaseType = phaseType;
|
||||
}
|
||||
|
||||
public Builder questionSet(final QuestionSetType questionSet)
|
||||
{
|
||||
this.questionSet = questionSet;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder cycles(final int cycles)
|
||||
{
|
||||
this.cycles = cycles;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder turnsPerCycle(final int turnsPerCycle)
|
||||
{
|
||||
this.turnsPerCycle = turnsPerCycle;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder turnOrder(final TurnOrderType turnOrder)
|
||||
{
|
||||
this.turnOrder = turnOrder;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder pickMode(final PickModeType pickMode)
|
||||
{
|
||||
this.pickMode = pickMode;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder teamMode(final TeamModeType teamMode)
|
||||
{
|
||||
this.teamMode = teamMode;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder teamAssignment(final TeamAssignmentType teamAssignment)
|
||||
{
|
||||
this.teamAssignment = teamAssignment;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder scoring(final ScoringProfile scoring)
|
||||
{
|
||||
this.scoring = scoring;
|
||||
return this;
|
||||
}
|
||||
|
||||
public DqflPhase build()
|
||||
{
|
||||
return new DqflPhase(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/main/java/de/dogfire/dqfl/model/DqflPool.java
Normal file
55
src/main/java/de/dogfire/dqfl/model/DqflPool.java
Normal file
@@ -0,0 +1,55 @@
|
||||
|
||||
package de.dogfire.dqfl.model;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Repräsentiert einen POOL-Block im DQFL-Dokument.
|
||||
*/
|
||||
public final class DqflPool
|
||||
{
|
||||
private final String alias;
|
||||
private final String sourceAlias;
|
||||
private final String remoteId;
|
||||
private final String title;
|
||||
|
||||
public DqflPool(
|
||||
final String alias,
|
||||
final String sourceAlias,
|
||||
final String remoteId,
|
||||
final String title)
|
||||
{
|
||||
this.alias = Objects.requireNonNull(alias, "alias must not be null");
|
||||
this.sourceAlias = Objects.requireNonNull(sourceAlias, "sourceAlias must not be null");
|
||||
this.remoteId = Objects.requireNonNull(remoteId, "remoteId must not be null");
|
||||
this.title = title; // optional gemäß Spec
|
||||
}
|
||||
|
||||
public String getAlias()
|
||||
{
|
||||
return this.alias;
|
||||
}
|
||||
|
||||
public String getSourceAlias()
|
||||
{
|
||||
return this.sourceAlias;
|
||||
}
|
||||
|
||||
public String getRemoteId()
|
||||
{
|
||||
return this.remoteId;
|
||||
}
|
||||
|
||||
/** Kann null sein — Titel ist optional. */
|
||||
public String getTitle()
|
||||
{
|
||||
return this.title;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return "POOL " + this.alias + " [source=" + this.sourceAlias
|
||||
+ ", remoteId=" + this.remoteId + "]";
|
||||
}
|
||||
}
|
||||
86
src/main/java/de/dogfire/dqfl/model/DqflRound.java
Normal file
86
src/main/java/de/dogfire/dqfl/model/DqflRound.java
Normal file
@@ -0,0 +1,86 @@
|
||||
|
||||
package de.dogfire.dqfl.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Repräsentiert einen ROUND-Block auf Root-Ebene.
|
||||
*/
|
||||
public final class DqflRound
|
||||
{
|
||||
private final String alias;
|
||||
private final String title;
|
||||
private final List<DqflCategory> categories;
|
||||
|
||||
private DqflRound(final Builder builder)
|
||||
{
|
||||
this.alias = Objects.requireNonNull(builder.alias, "alias must not be null");
|
||||
this.title = builder.title;
|
||||
this.categories = Collections.unmodifiableList(new ArrayList<>(builder.categories));
|
||||
}
|
||||
|
||||
public String getAlias()
|
||||
{
|
||||
return this.alias;
|
||||
}
|
||||
|
||||
/** Kann null sein — TITLE ist optional. */
|
||||
public String getTitle()
|
||||
{
|
||||
return this.title;
|
||||
}
|
||||
|
||||
public List<DqflCategory> getCategories()
|
||||
{
|
||||
return this.categories;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return "ROUND " + this.alias
|
||||
+ " [title=" + this.title
|
||||
+ ", categories=" + this.categories.size() + "]";
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Builder
|
||||
// ================================================================
|
||||
|
||||
public static Builder builder(final String alias)
|
||||
{
|
||||
return new Builder(alias);
|
||||
}
|
||||
|
||||
public static final class Builder
|
||||
{
|
||||
private final String alias;
|
||||
private String title;
|
||||
private final List<DqflCategory> categories = new ArrayList<>();
|
||||
|
||||
private Builder(final String alias)
|
||||
{
|
||||
this.alias = alias;
|
||||
}
|
||||
|
||||
public Builder title(final String title)
|
||||
{
|
||||
this.title = title;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addCategory(final DqflCategory category)
|
||||
{
|
||||
this.categories.add(Objects.requireNonNull(category));
|
||||
return this;
|
||||
}
|
||||
|
||||
public DqflRound build()
|
||||
{
|
||||
return new DqflRound(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/main/java/de/dogfire/dqfl/model/DqflSource.java
Normal file
42
src/main/java/de/dogfire/dqfl/model/DqflSource.java
Normal file
@@ -0,0 +1,42 @@
|
||||
|
||||
package de.dogfire.dqfl.model;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Repräsentiert einen SOURCE-Block im DQFL-Dokument.
|
||||
*/
|
||||
public final class DqflSource
|
||||
{
|
||||
private final String alias;
|
||||
private final SourceType type;
|
||||
private final String connection;
|
||||
|
||||
public DqflSource(final String alias, final SourceType type, final String connection)
|
||||
{
|
||||
this.alias = Objects.requireNonNull(alias, "alias must not be null");
|
||||
this.type = Objects.requireNonNull(type, "type must not be null");
|
||||
this.connection = Objects.requireNonNull(connection, "connection must not be null");
|
||||
}
|
||||
|
||||
public String getAlias()
|
||||
{
|
||||
return this.alias;
|
||||
}
|
||||
|
||||
public SourceType getType()
|
||||
{
|
||||
return this.type;
|
||||
}
|
||||
|
||||
public String getConnection()
|
||||
{
|
||||
return this.connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return "SOURCE " + this.alias + " [" + this.type + " -> " + this.connection + "]";
|
||||
}
|
||||
}
|
||||
96
src/main/java/de/dogfire/dqfl/model/GridSize.java
Normal file
96
src/main/java/de/dogfire/dqfl/model/GridSize.java
Normal file
@@ -0,0 +1,96 @@
|
||||
|
||||
package de.dogfire.dqfl.model;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Repräsentiert eine SIZE-Angabe bei GRID_2D Layout.
|
||||
* Format: rows x cols (z.B. "6x6").
|
||||
*/
|
||||
public final class GridSize
|
||||
{
|
||||
private final int rows;
|
||||
private final int cols;
|
||||
|
||||
public GridSize(final int rows, final int cols)
|
||||
{
|
||||
if(rows <= 0 || cols <= 0)
|
||||
{
|
||||
throw new IllegalArgumentException(
|
||||
"rows and cols must be positive integers, got " + rows + "x" + cols);
|
||||
}
|
||||
this.rows = rows;
|
||||
this.cols = cols;
|
||||
}
|
||||
|
||||
public int getRows()
|
||||
{
|
||||
return this.rows;
|
||||
}
|
||||
|
||||
public int getCols()
|
||||
{
|
||||
return this.cols;
|
||||
}
|
||||
|
||||
public int getTotalCells()
|
||||
{
|
||||
return this.rows * this.cols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst eine SIZE-Angabe im Format "NxM".
|
||||
*
|
||||
* @return GridSize oder null bei ungültigem Format
|
||||
*/
|
||||
public static GridSize parse(final String value)
|
||||
{
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
final String[] parts = value.split("x", 2);
|
||||
if(parts.length != 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
final int rows = Integer.parseInt(parts[0].trim());
|
||||
final int cols = Integer.parseInt(parts[1].trim());
|
||||
return new GridSize(rows, cols);
|
||||
}
|
||||
catch(final NumberFormatException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return this.rows + "x" + this.cols;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o)
|
||||
{
|
||||
if(this == o)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if(!(o instanceof final GridSize that))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return this.rows == that.rows && this.cols == that.cols;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode()
|
||||
{
|
||||
return Objects.hash(this.rows, this.cols);
|
||||
}
|
||||
}
|
||||
23
src/main/java/de/dogfire/dqfl/model/LayoutType.java
Normal file
23
src/main/java/de/dogfire/dqfl/model/LayoutType.java
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
package de.dogfire.dqfl.model;
|
||||
|
||||
/**
|
||||
* Layout-Typen gemäß DQFL V1.
|
||||
* V1 unterstützt ausschließlich GRID_2D.
|
||||
*/
|
||||
public enum LayoutType
|
||||
{
|
||||
GRID_2D;
|
||||
|
||||
public static LayoutType parse(final String value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return LayoutType.valueOf(value);
|
||||
}
|
||||
catch(final IllegalArgumentException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/main/java/de/dogfire/dqfl/model/PhaseType.java
Normal file
23
src/main/java/de/dogfire/dqfl/model/PhaseType.java
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
package de.dogfire.dqfl.model;
|
||||
|
||||
/**
|
||||
* Phase-Typen gemäß DQFL V1: NORMAL und BOSS.
|
||||
*/
|
||||
public enum PhaseType
|
||||
{
|
||||
NORMAL,
|
||||
BOSS;
|
||||
|
||||
public static PhaseType parse(final String value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return PhaseType.valueOf(value);
|
||||
}
|
||||
catch(final IllegalArgumentException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/main/java/de/dogfire/dqfl/model/PickModeType.java
Normal file
23
src/main/java/de/dogfire/dqfl/model/PickModeType.java
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
package de.dogfire.dqfl.model;
|
||||
|
||||
/**
|
||||
* PICK_MODE Werte gemäß DQFL V1.
|
||||
* V1 unterstützt ausschließlich FREE_UNANSWERED.
|
||||
*/
|
||||
public enum PickModeType
|
||||
{
|
||||
FREE_UNANSWERED;
|
||||
|
||||
public static PickModeType parse(final String value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return PickModeType.valueOf(value);
|
||||
}
|
||||
catch(final IllegalArgumentException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/main/java/de/dogfire/dqfl/model/QuestionSetType.java
Normal file
23
src/main/java/de/dogfire/dqfl/model/QuestionSetType.java
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
package de.dogfire.dqfl.model;
|
||||
|
||||
/**
|
||||
* QUESTION_SET Werte gemäß DQFL V1.
|
||||
*/
|
||||
public enum QuestionSetType
|
||||
{
|
||||
NORMAL,
|
||||
BOSS;
|
||||
|
||||
public static QuestionSetType parse(final String value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return QuestionSetType.valueOf(value);
|
||||
}
|
||||
catch(final IllegalArgumentException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/main/java/de/dogfire/dqfl/model/ScoringProfile.java
Normal file
45
src/main/java/de/dogfire/dqfl/model/ScoringProfile.java
Normal file
@@ -0,0 +1,45 @@
|
||||
|
||||
package de.dogfire.dqfl.model;
|
||||
|
||||
/**
|
||||
* Vordefinierte Scoring-Profile gemäß DQFL V1.
|
||||
*/
|
||||
public enum ScoringProfile
|
||||
{
|
||||
/** Richtige Antwort +1, falsche Antwort 0. */
|
||||
SOLO_PLUS1_NO_PENALTY(1, 0),
|
||||
|
||||
/** Jeder Spieler eines richtigen Teams erhält +2; Teams werden unabhängig ausgewertet. */
|
||||
BOSS_2V2_PLUS2_PER_CORRECT_PLAYER(2, 0);
|
||||
|
||||
private final int pointsCorrect;
|
||||
private final int pointsWrong;
|
||||
|
||||
ScoringProfile(final int pointsCorrect, final int pointsWrong)
|
||||
{
|
||||
this.pointsCorrect = pointsCorrect;
|
||||
this.pointsWrong = pointsWrong;
|
||||
}
|
||||
|
||||
public int getPointsCorrect()
|
||||
{
|
||||
return this.pointsCorrect;
|
||||
}
|
||||
|
||||
public int getPointsWrong()
|
||||
{
|
||||
return this.pointsWrong;
|
||||
}
|
||||
|
||||
public static ScoringProfile parse(final String value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ScoringProfile.valueOf(value);
|
||||
}
|
||||
catch(final IllegalArgumentException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/main/java/de/dogfire/dqfl/model/SourceType.java
Normal file
22
src/main/java/de/dogfire/dqfl/model/SourceType.java
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
package de.dogfire.dqfl.model;
|
||||
|
||||
/**
|
||||
* SOURCE TYPE Werte gemäß DQFL V1.
|
||||
*/
|
||||
public enum SourceType
|
||||
{
|
||||
REST;
|
||||
|
||||
public static SourceType parse(final String value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return SourceType.valueOf(value);
|
||||
}
|
||||
catch(final IllegalArgumentException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/main/java/de/dogfire/dqfl/model/TeamAssignmentType.java
Normal file
23
src/main/java/de/dogfire/dqfl/model/TeamAssignmentType.java
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
package de.dogfire.dqfl.model;
|
||||
|
||||
/**
|
||||
* TEAM_ASSIGNMENT Werte gemäß DQFL V1.
|
||||
* V1 unterstützt ausschließlich RANDOM.
|
||||
*/
|
||||
public enum TeamAssignmentType
|
||||
{
|
||||
RANDOM;
|
||||
|
||||
public static TeamAssignmentType parse(final String value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return TeamAssignmentType.valueOf(value);
|
||||
}
|
||||
catch(final IllegalArgumentException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/main/java/de/dogfire/dqfl/model/TeamModeType.java
Normal file
23
src/main/java/de/dogfire/dqfl/model/TeamModeType.java
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
package de.dogfire.dqfl.model;
|
||||
|
||||
/**
|
||||
* TEAM_MODE Werte gemäß DQFL V1.
|
||||
*/
|
||||
public enum TeamModeType
|
||||
{
|
||||
SOLO,
|
||||
TWO_VS_TWO;
|
||||
|
||||
public static TeamModeType parse(final String value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return TeamModeType.valueOf(value);
|
||||
}
|
||||
catch(final IllegalArgumentException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/main/java/de/dogfire/dqfl/model/TurnOrderType.java
Normal file
23
src/main/java/de/dogfire/dqfl/model/TurnOrderType.java
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
package de.dogfire.dqfl.model;
|
||||
|
||||
/**
|
||||
* TURN_ORDER Werte gemäß DQFL V1.
|
||||
* V1 unterstützt ausschließlich ROTATING_START.
|
||||
*/
|
||||
public enum TurnOrderType
|
||||
{
|
||||
ROTATING_START;
|
||||
|
||||
public static TurnOrderType parse(final String value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return TurnOrderType.valueOf(value);
|
||||
}
|
||||
catch(final IllegalArgumentException e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
673
src/main/java/de/dogfire/dqfl/parser/DqflParser.java
Normal file
673
src/main/java/de/dogfire/dqfl/parser/DqflParser.java
Normal file
@@ -0,0 +1,673 @@
|
||||
package de.dogfire.dqfl.parser;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import de.dogfire.dqfl.error.DqflError;
|
||||
import de.dogfire.dqfl.error.DqflParseException;
|
||||
import de.dogfire.dqfl.model.*;
|
||||
|
||||
/**
|
||||
* Parser für DQFL V1 Dokumente.
|
||||
* Liest ein einrückungssensitives Textformat und erzeugt einen {@link DqflDocument} AST.
|
||||
*/
|
||||
public final class DqflParser
|
||||
{
|
||||
private final List<DqflError> errors = new ArrayList<>();
|
||||
|
||||
private List<ParsedLine> lines;
|
||||
private int cursor;
|
||||
private final Set<String> seenSourceAliases = new HashSet<>();
|
||||
private final Set<String> seenPoolAliases = new HashSet<>();
|
||||
|
||||
/**
|
||||
* Parst den gegebenen DQFL-Text und gibt ein DqflDocument zurück.
|
||||
*
|
||||
* @throws DqflParseException wenn fatale Parse-Fehler auftreten
|
||||
*/
|
||||
public DqflDocument parse(final String input) throws DqflParseException
|
||||
{
|
||||
this.errors.clear();
|
||||
this.cursor = 0;
|
||||
this.seenSourceAliases.clear();
|
||||
this.seenPoolAliases.clear();
|
||||
|
||||
if(input == null || input.isBlank())
|
||||
{
|
||||
throw new DqflParseException("Input is empty or null");
|
||||
}
|
||||
|
||||
this.lines = this.tokenize(input);
|
||||
|
||||
if(this.lines.isEmpty())
|
||||
{
|
||||
throw new DqflParseException("No parseable lines found");
|
||||
}
|
||||
|
||||
final DqflDocument.Builder docBuilder = DqflDocument.builder();
|
||||
boolean hasVersion = false;
|
||||
boolean hasFlow = false;
|
||||
|
||||
while(this.cursor < this.lines.size())
|
||||
{
|
||||
final ParsedLine line = this.lines.get(this.cursor);
|
||||
|
||||
if(line.indent != 0)
|
||||
{
|
||||
this.addError(line, "Unexpected indented line at root level");
|
||||
this.cursor++;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch(line.keyword)
|
||||
{
|
||||
case "DQFL_VERSION" -> {
|
||||
if(hasVersion)
|
||||
{
|
||||
this.addError(line, "DQFL_VERSION must appear exactly once");
|
||||
}
|
||||
else if(line.value == null || line.value.isBlank())
|
||||
{
|
||||
this.addError(line, "DQFL_VERSION requires a value");
|
||||
}
|
||||
else
|
||||
{
|
||||
docBuilder.dqflVersion(line.value);
|
||||
hasVersion = true;
|
||||
}
|
||||
this.cursor++;
|
||||
}
|
||||
case "FLOW" -> {
|
||||
if(hasFlow)
|
||||
{
|
||||
this.addError(line, "FLOW must appear exactly once");
|
||||
}
|
||||
else if(line.value == null || line.value.isBlank())
|
||||
{
|
||||
this.addError(line, "FLOW requires a value");
|
||||
}
|
||||
else
|
||||
{
|
||||
docBuilder.flowName(this.unquote(line.value));
|
||||
hasFlow = true;
|
||||
}
|
||||
this.cursor++;
|
||||
}
|
||||
case "SCRIPT_REVISION" -> {
|
||||
docBuilder.scriptRevision(this.parseIntSafe(line, line.value));
|
||||
this.cursor++;
|
||||
}
|
||||
case "SOURCE" -> {
|
||||
final DqflSource source = this.parseSource(line);
|
||||
if(source != null)
|
||||
{
|
||||
docBuilder.addSource(source);
|
||||
}
|
||||
}
|
||||
case "POOL" -> {
|
||||
final DqflPool pool = this.parsePool(line);
|
||||
if(pool != null)
|
||||
{
|
||||
docBuilder.addPool(pool);
|
||||
}
|
||||
}
|
||||
case "ROUND" -> {
|
||||
final DqflRound round = this.parseRound(line);
|
||||
if(round != null)
|
||||
{
|
||||
docBuilder.addRound(round);
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
this.addError(line, "Unknown root keyword: " + line.keyword);
|
||||
this.cursor++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!hasVersion)
|
||||
{
|
||||
this.errors.add(DqflError.error("DQFL_VERSION is required"));
|
||||
}
|
||||
if(!hasFlow)
|
||||
{
|
||||
this.errors.add(DqflError.error("FLOW is required"));
|
||||
}
|
||||
|
||||
if(this.errors.stream().anyMatch(DqflError::isError))
|
||||
{
|
||||
throw new DqflParseException(
|
||||
"DQFL parsing failed with " + this.errors.size() + " error(s)",
|
||||
this.errors);
|
||||
}
|
||||
|
||||
return docBuilder.build();
|
||||
}
|
||||
|
||||
public List<DqflError> getWarnings()
|
||||
{
|
||||
return this.errors.stream().filter(e -> !e.isError()).toList();
|
||||
}
|
||||
|
||||
private DqflSource parseSource(final ParsedLine headerLine)
|
||||
{
|
||||
final String alias = headerLine.value;
|
||||
if(alias == null || alias.isBlank())
|
||||
{
|
||||
this.addError(headerLine, "SOURCE requires an alias");
|
||||
this.cursor++;
|
||||
return null;
|
||||
}
|
||||
if(!this.seenSourceAliases.add(alias))
|
||||
{
|
||||
this.addError(headerLine, "Duplicate SOURCE alias: " + alias);
|
||||
}
|
||||
|
||||
this.cursor++;
|
||||
|
||||
String type = null;
|
||||
String connection = null;
|
||||
|
||||
while(this.cursor < this.lines.size())
|
||||
{
|
||||
final ParsedLine child = this.lines.get(this.cursor);
|
||||
if(child.indent <= headerLine.indent)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
switch(child.keyword)
|
||||
{
|
||||
case "TYPE" -> type = child.value;
|
||||
case "CONNECTION" -> connection = child.value;
|
||||
default -> this.addError(child,
|
||||
"Unknown keyword in SOURCE block: " + child.keyword);
|
||||
}
|
||||
this.cursor++;
|
||||
}
|
||||
|
||||
if(type == null || connection == null)
|
||||
{
|
||||
this.addError(headerLine,
|
||||
"SOURCE " + alias + " requires both TYPE and CONNECTION");
|
||||
return null;
|
||||
}
|
||||
|
||||
final SourceType sourceType = SourceType.parse(type);
|
||||
if(sourceType == null)
|
||||
{
|
||||
this.addError(headerLine, "Unknown SOURCE TYPE: " + type);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DqflSource(alias, sourceType, connection);
|
||||
}
|
||||
|
||||
private DqflPool parsePool(final ParsedLine headerLine)
|
||||
{
|
||||
final String alias = headerLine.value;
|
||||
if(alias == null || alias.isBlank())
|
||||
{
|
||||
this.addError(headerLine, "POOL requires an alias");
|
||||
this.cursor++;
|
||||
return null;
|
||||
}
|
||||
if(!this.seenPoolAliases.add(alias))
|
||||
{
|
||||
this.addError(headerLine, "Duplicate POOL alias: " + alias);
|
||||
}
|
||||
|
||||
this.cursor++;
|
||||
|
||||
String source = null;
|
||||
String remoteId = null;
|
||||
String title = null;
|
||||
|
||||
while(this.cursor < this.lines.size())
|
||||
{
|
||||
final ParsedLine child = this.lines.get(this.cursor);
|
||||
if(child.indent <= headerLine.indent)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
switch(child.keyword)
|
||||
{
|
||||
case "SOURCE" -> source = child.value;
|
||||
case "REMOTE_ID" -> remoteId = this.unquote(child.value);
|
||||
case "TITLE" -> title = this.unquote(child.value);
|
||||
default -> this.addError(child,
|
||||
"Unknown keyword in POOL block: " + child.keyword);
|
||||
}
|
||||
this.cursor++;
|
||||
}
|
||||
|
||||
if(source == null || remoteId == null)
|
||||
{
|
||||
this.addError(headerLine,
|
||||
"POOL " + alias + " requires both SOURCE and REMOTE_ID");
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DqflPool(alias, source, remoteId, title);
|
||||
}
|
||||
|
||||
private DqflRound parseRound(final ParsedLine headerLine)
|
||||
{
|
||||
final String alias = headerLine.value;
|
||||
if(alias == null || alias.isBlank())
|
||||
{
|
||||
this.addError(headerLine, "ROUND requires an alias");
|
||||
this.cursor++;
|
||||
return null;
|
||||
}
|
||||
|
||||
this.cursor++;
|
||||
final DqflRound.Builder roundBuilder = DqflRound.builder(alias);
|
||||
|
||||
while(this.cursor < this.lines.size())
|
||||
{
|
||||
final ParsedLine child = this.lines.get(this.cursor);
|
||||
if(child.indent <= headerLine.indent)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
switch(child.keyword)
|
||||
{
|
||||
case "TITLE" -> {
|
||||
roundBuilder.title(this.unquote(child.value));
|
||||
this.cursor++;
|
||||
}
|
||||
case "CATEGORY" -> {
|
||||
final DqflCategory category = this.parseCategory(child);
|
||||
if(category != null)
|
||||
{
|
||||
roundBuilder.addCategory(category);
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
this.addError(child,
|
||||
"Unknown keyword in ROUND block: " + child.keyword);
|
||||
this.cursor++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return roundBuilder.build();
|
||||
}
|
||||
|
||||
private DqflCategory parseCategory(final ParsedLine headerLine)
|
||||
{
|
||||
final String alias = headerLine.value;
|
||||
if(alias == null || alias.isBlank())
|
||||
{
|
||||
this.addError(headerLine, "CATEGORY requires an alias");
|
||||
this.cursor++;
|
||||
return null;
|
||||
}
|
||||
|
||||
this.cursor++;
|
||||
final DqflCategory.Builder catBuilder = DqflCategory.builder(alias);
|
||||
|
||||
while(this.cursor < this.lines.size())
|
||||
{
|
||||
final ParsedLine child = this.lines.get(this.cursor);
|
||||
if(child.indent <= headerLine.indent)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
switch(child.keyword)
|
||||
{
|
||||
case "TITLE" -> {
|
||||
catBuilder.title(this.unquote(child.value));
|
||||
this.cursor++;
|
||||
}
|
||||
case "USE_POOL" -> {
|
||||
catBuilder.usePool(child.value);
|
||||
this.cursor++;
|
||||
}
|
||||
case "LAYOUT" -> {
|
||||
final LayoutType lt = LayoutType.parse(child.value);
|
||||
if(lt == null)
|
||||
{
|
||||
this.addError(child, "Unknown LAYOUT type: " + child.value);
|
||||
}
|
||||
else
|
||||
{
|
||||
catBuilder.layout(lt);
|
||||
}
|
||||
this.cursor++;
|
||||
}
|
||||
case "SIZE" -> {
|
||||
final GridSize gs = GridSize.parse(child.value);
|
||||
if(gs == null)
|
||||
{
|
||||
this.addError(child,
|
||||
"Invalid SIZE format: " + child.value + " (expected NxM)");
|
||||
}
|
||||
else
|
||||
{
|
||||
catBuilder.size(gs);
|
||||
}
|
||||
this.cursor++;
|
||||
}
|
||||
case "PHASE" -> {
|
||||
final DqflPhase phase = this.parsePhase(child);
|
||||
if(phase != null)
|
||||
{
|
||||
catBuilder.addPhase(phase);
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
this.addError(child,
|
||||
"Unknown keyword in CATEGORY block: " + child.keyword);
|
||||
this.cursor++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return catBuilder.build();
|
||||
}
|
||||
|
||||
private DqflPhase parsePhase(final ParsedLine headerLine)
|
||||
{
|
||||
final PhaseType phaseType = PhaseType.parse(headerLine.value);
|
||||
if(phaseType == null)
|
||||
{
|
||||
this.addError(headerLine, "Unknown PHASE type: " + headerLine.value);
|
||||
this.cursor++;
|
||||
return null;
|
||||
}
|
||||
|
||||
this.cursor++;
|
||||
final DqflPhase.Builder phaseBuilder = DqflPhase.builder(phaseType);
|
||||
|
||||
while(this.cursor < this.lines.size())
|
||||
{
|
||||
final ParsedLine child = this.lines.get(this.cursor);
|
||||
if(child.indent <= headerLine.indent)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
switch(child.keyword)
|
||||
{
|
||||
case "QUESTION_SET" -> {
|
||||
final QuestionSetType qs = QuestionSetType.parse(child.value);
|
||||
if(qs == null)
|
||||
{
|
||||
this.addError(child, "Unknown QUESTION_SET: " + child.value);
|
||||
}
|
||||
else
|
||||
{
|
||||
phaseBuilder.questionSet(qs);
|
||||
}
|
||||
}
|
||||
case "CYCLES" -> {
|
||||
final Integer v = this.parseIntSafe(child, child.value);
|
||||
if(v != null)
|
||||
{
|
||||
phaseBuilder.cycles(v);
|
||||
}
|
||||
}
|
||||
case "TURNS_PER_CYCLE" -> {
|
||||
final Integer v = this.parseIntSafe(child, child.value);
|
||||
if(v != null)
|
||||
{
|
||||
phaseBuilder.turnsPerCycle(v);
|
||||
}
|
||||
}
|
||||
case "TURN_ORDER" -> {
|
||||
final TurnOrderType to = TurnOrderType.parse(child.value);
|
||||
if(to == null)
|
||||
{
|
||||
this.addError(child, "Unknown TURN_ORDER: " + child.value);
|
||||
}
|
||||
else
|
||||
{
|
||||
phaseBuilder.turnOrder(to);
|
||||
}
|
||||
}
|
||||
case "PICK_MODE" -> {
|
||||
final PickModeType pm = PickModeType.parse(child.value);
|
||||
if(pm == null)
|
||||
{
|
||||
this.addError(child, "Unknown PICK_MODE: " + child.value);
|
||||
}
|
||||
else
|
||||
{
|
||||
phaseBuilder.pickMode(pm);
|
||||
}
|
||||
}
|
||||
case "TEAM_MODE" -> {
|
||||
final TeamModeType tm = TeamModeType.parse(child.value);
|
||||
if(tm == null)
|
||||
{
|
||||
this.addError(child, "Unknown TEAM_MODE: " + child.value);
|
||||
}
|
||||
else
|
||||
{
|
||||
phaseBuilder.teamMode(tm);
|
||||
}
|
||||
}
|
||||
case "TEAM_ASSIGNMENT" -> {
|
||||
final TeamAssignmentType ta = TeamAssignmentType.parse(child.value);
|
||||
if(ta == null)
|
||||
{
|
||||
this.addError(child, "Unknown TEAM_ASSIGNMENT: " + child.value);
|
||||
}
|
||||
else
|
||||
{
|
||||
phaseBuilder.teamAssignment(ta);
|
||||
}
|
||||
}
|
||||
case "SCORING" -> {
|
||||
final ScoringProfile sp = ScoringProfile.parse(child.value);
|
||||
if(sp == null)
|
||||
{
|
||||
this.addError(child, "Unknown SCORING profile: " + child.value);
|
||||
}
|
||||
else
|
||||
{
|
||||
phaseBuilder.scoring(sp);
|
||||
}
|
||||
}
|
||||
default -> this.addError(child,
|
||||
"Unknown keyword in PHASE block: " + child.keyword);
|
||||
}
|
||||
this.cursor++;
|
||||
}
|
||||
|
||||
return phaseBuilder.build();
|
||||
}
|
||||
|
||||
private List<ParsedLine> tokenize(final String input)
|
||||
{
|
||||
final List<ParsedLine> result = new ArrayList<>();
|
||||
final String[] rawLines = input.split("\\r?\\n");
|
||||
|
||||
Character indentChar = null;
|
||||
int indentUnit = -1;
|
||||
int previousIndent = 0;
|
||||
|
||||
for(int i = 0; i < rawLines.length; i++)
|
||||
{
|
||||
final String raw = rawLines[i];
|
||||
final int lineNumber = i + 1;
|
||||
final String trimmed = raw.trim();
|
||||
|
||||
if(trimmed.isEmpty() || trimmed.startsWith("#"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
final int contentStart = this.findFirstNonWhitespace(raw);
|
||||
if(contentStart < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
final String indentation = raw.substring(0, contentStart);
|
||||
|
||||
if(!indentation.isEmpty())
|
||||
{
|
||||
final boolean hasSpaces = indentation.indexOf(' ') >= 0;
|
||||
final boolean hasTabs = indentation.indexOf('\t') >= 0;
|
||||
if(hasSpaces && hasTabs)
|
||||
{
|
||||
this.errors.add(DqflError.error(lineNumber,
|
||||
"Mixed tabs and spaces in indentation"));
|
||||
continue;
|
||||
}
|
||||
|
||||
final char lineIndentChar = indentation.charAt(0);
|
||||
if(indentChar == null)
|
||||
{
|
||||
indentChar = lineIndentChar;
|
||||
}
|
||||
else if(lineIndentChar != indentChar)
|
||||
{
|
||||
this.errors.add(DqflError.error(lineNumber,
|
||||
"Inconsistent indentation character; use either tabs or spaces consistently"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
int indent = 0;
|
||||
if(!indentation.isEmpty())
|
||||
{
|
||||
if(indentUnit < 0)
|
||||
{
|
||||
indentUnit = indentation.length();
|
||||
if(indentUnit <= 0)
|
||||
{
|
||||
this.errors.add(DqflError.error(lineNumber, "Invalid indentation width"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if(indentation.length() % indentUnit != 0)
|
||||
{
|
||||
this.errors.add(DqflError.error(lineNumber,
|
||||
"Indentation does not match the configured indentation width of " + indentUnit));
|
||||
continue;
|
||||
}
|
||||
|
||||
indent = indentation.length() / indentUnit;
|
||||
if(indent > previousIndent + 1)
|
||||
{
|
||||
this.errors.add(DqflError.error(lineNumber,
|
||||
"Indentation may only increase by one level at a time"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
indent = 0;
|
||||
}
|
||||
|
||||
final int spaceIdx = trimmed.indexOf(' ');
|
||||
final String keyword;
|
||||
final String value;
|
||||
|
||||
if(spaceIdx < 0)
|
||||
{
|
||||
keyword = trimmed;
|
||||
value = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
keyword = trimmed.substring(0, spaceIdx);
|
||||
value = trimmed.substring(spaceIdx + 1).trim();
|
||||
}
|
||||
|
||||
result.add(new ParsedLine(lineNumber, indent, keyword, value));
|
||||
previousIndent = indent;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private int findFirstNonWhitespace(final String raw)
|
||||
{
|
||||
for(int i = 0; i < raw.length(); i++)
|
||||
{
|
||||
final char ch = raw.charAt(i);
|
||||
if(ch != ' ' && ch != '\t')
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private String unquote(final String value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
String v = value.trim();
|
||||
if(v.length() >= 2 && v.startsWith("\"") && v.endsWith("\""))
|
||||
{
|
||||
v = v.substring(1, v.length() - 1);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
private Integer parseIntSafe(final ParsedLine line, final String value)
|
||||
{
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
this.addError(line, line.keyword + " requires a numeric value");
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
final int v = Integer.parseInt(value.trim());
|
||||
if(v <= 0)
|
||||
{
|
||||
this.addError(line, line.keyword + " must be a positive integer, got " + v);
|
||||
return null;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
catch(final NumberFormatException e)
|
||||
{
|
||||
this.addError(line,
|
||||
line.keyword + " is not a valid integer: " + value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void addError(final ParsedLine line, final String message)
|
||||
{
|
||||
this.errors.add(DqflError.error(line.lineNumber, message));
|
||||
}
|
||||
|
||||
private static final class ParsedLine
|
||||
{
|
||||
final int lineNumber;
|
||||
final int indent;
|
||||
final String keyword;
|
||||
final String value;
|
||||
|
||||
ParsedLine(final int lineNumber, final int indent, final String keyword, final String value)
|
||||
{
|
||||
this.lineNumber = lineNumber;
|
||||
this.indent = indent;
|
||||
this.keyword = keyword;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return "L" + this.lineNumber + " [" + this.indent + "] "
|
||||
+ this.keyword + (this.value != null ? " " + this.value : "");
|
||||
}
|
||||
}
|
||||
}
|
||||
265
src/main/java/de/dogfire/dqfl/validator/DqflValidator.java
Normal file
265
src/main/java/de/dogfire/dqfl/validator/DqflValidator.java
Normal file
@@ -0,0 +1,265 @@
|
||||
|
||||
package de.dogfire.dqfl.validator;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import de.dogfire.dqfl.error.DqflError;
|
||||
import de.dogfire.dqfl.error.DqflValidationResult;
|
||||
import de.dogfire.dqfl.model.*;
|
||||
|
||||
/**
|
||||
* Validator für DQFL V1 Dokumente.
|
||||
* Prüft alle Fachregeln aus Kapitel 10 der Spezifikation.
|
||||
*/
|
||||
public final class DqflValidator
|
||||
{
|
||||
/** Empfohlenes Validierungsmuster für REMOTE_ID: ^[A-Z0-9]{8}[0-9]{10,}$ */
|
||||
private static final Pattern REMOTE_ID_PATTERN =
|
||||
Pattern.compile("^[A-Z0-9]{8}[0-9]{10,}$");
|
||||
|
||||
/**
|
||||
* Validiert ein geparstes DqflDocument und gibt das Ergebnis zurück.
|
||||
*/
|
||||
public DqflValidationResult validate(final DqflDocument document)
|
||||
{
|
||||
final List<DqflError> errors = new ArrayList<>();
|
||||
|
||||
// 1. DQFL_VERSION muss vorhanden sein
|
||||
if(document.getDqflVersion() == null || document.getDqflVersion().isBlank())
|
||||
{
|
||||
errors.add(DqflError.error("DQFL_VERSION is required"));
|
||||
}
|
||||
|
||||
// 2. FLOW muss vorhanden sein
|
||||
if(document.getFlowName() == null || document.getFlowName().isBlank())
|
||||
{
|
||||
errors.add(DqflError.error("FLOW is required"));
|
||||
}
|
||||
|
||||
// 3. SOURCE-Aliase müssen eindeutig sein (bereits durch Map im Parser sichergestellt,
|
||||
// aber wir prüfen trotzdem ob Sources vorhanden sind wenn Pools sie referenzieren)
|
||||
|
||||
// 4. POOL-Validierung
|
||||
this.validatePools(document, errors);
|
||||
|
||||
// 5. ROUND-Validierung
|
||||
this.validateRounds(document, errors);
|
||||
|
||||
// 6. Alias-Eindeutigkeit über Objektarten
|
||||
this.validateAliasUniqueness(document, errors);
|
||||
|
||||
return new DqflValidationResult(errors);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Pool-Validierung
|
||||
// ================================================================
|
||||
|
||||
private void validatePools(final DqflDocument document, final List<DqflError> errors)
|
||||
{
|
||||
for(final DqflPool pool : document.getPools().values())
|
||||
{
|
||||
// SOURCE-Referenz muss existieren
|
||||
if(document.getSource(pool.getSourceAlias()) == null)
|
||||
{
|
||||
errors.add(DqflError.error(
|
||||
"POOL '" + pool.getAlias()
|
||||
+ "' references unknown SOURCE '" + pool.getSourceAlias() + "'"));
|
||||
}
|
||||
|
||||
// REMOTE_ID Format prüfen (als Warnung, nicht als Fehler)
|
||||
if(!REMOTE_ID_PATTERN.matcher(pool.getRemoteId()).matches())
|
||||
{
|
||||
errors.add(DqflError.warning(
|
||||
"POOL '" + pool.getAlias()
|
||||
+ "' REMOTE_ID '" + pool.getRemoteId()
|
||||
+ "' does not match recommended pattern ^[A-Z0-9]{8}[0-9]{10,}$"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Round-Validierung
|
||||
// ================================================================
|
||||
|
||||
private void validateRounds(final DqflDocument document, final List<DqflError> errors)
|
||||
{
|
||||
if(document.getRounds().isEmpty())
|
||||
{
|
||||
errors.add(DqflError.error("Document must contain at least one ROUND"));
|
||||
return;
|
||||
}
|
||||
|
||||
final Set<String> roundAliases = new HashSet<>();
|
||||
final Set<String> categoryAliases = new HashSet<>();
|
||||
|
||||
for(final DqflRound round : document.getRounds())
|
||||
{
|
||||
// ROUND-Alias Eindeutigkeit
|
||||
if(!roundAliases.add(round.getAlias()))
|
||||
{
|
||||
errors.add(DqflError.error(
|
||||
"Duplicate ROUND alias: " + round.getAlias()));
|
||||
}
|
||||
|
||||
if(round.getCategories().isEmpty())
|
||||
{
|
||||
errors.add(DqflError.error(
|
||||
"ROUND '" + round.getAlias() + "' must contain at least one CATEGORY"));
|
||||
}
|
||||
|
||||
for(final DqflCategory category : round.getCategories())
|
||||
{
|
||||
this.validateCategory(category, document, categoryAliases, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Category-Validierung
|
||||
// ================================================================
|
||||
|
||||
private void validateCategory(
|
||||
final DqflCategory category,
|
||||
final DqflDocument document,
|
||||
final Set<String> categoryAliases,
|
||||
final List<DqflError> errors)
|
||||
{
|
||||
// CATEGORY-Alias Eindeutigkeit
|
||||
if(!categoryAliases.add(category.getAlias()))
|
||||
{
|
||||
errors.add(DqflError.error(
|
||||
"Duplicate CATEGORY alias: " + category.getAlias()));
|
||||
}
|
||||
|
||||
// Jede CATEGORY muss genau einen USE_POOL enthalten
|
||||
if(category.getUsePool() == null || category.getUsePool().isBlank())
|
||||
{
|
||||
errors.add(DqflError.error(
|
||||
"CATEGORY '" + category.getAlias() + "' must have exactly one USE_POOL"));
|
||||
}
|
||||
else
|
||||
{
|
||||
// USE_POOL muss auf existierenden POOL verweisen
|
||||
if(document.getPool(category.getUsePool()) == null)
|
||||
{
|
||||
errors.add(DqflError.error(
|
||||
"CATEGORY '" + category.getAlias()
|
||||
+ "' references unknown POOL '" + category.getUsePool() + "'"));
|
||||
}
|
||||
}
|
||||
|
||||
// SIZE nur gültig wenn LAYOUT vorhanden
|
||||
if(category.getSize() != null && category.getLayout() == null)
|
||||
{
|
||||
errors.add(DqflError.error(
|
||||
"CATEGORY '" + category.getAlias()
|
||||
+ "' has SIZE without LAYOUT"));
|
||||
}
|
||||
|
||||
// Bei GRID_2D muss SIZE vorhanden sein
|
||||
if(category.getLayout() == LayoutType.GRID_2D && category.getSize() == null)
|
||||
{
|
||||
errors.add(DqflError.error(
|
||||
"CATEGORY '" + category.getAlias()
|
||||
+ "' has LAYOUT GRID_2D but no SIZE specified"));
|
||||
}
|
||||
|
||||
// Jede CATEGORY muss mindestens eine PHASE enthalten
|
||||
if(category.getPhases().isEmpty())
|
||||
{
|
||||
errors.add(DqflError.error(
|
||||
"CATEGORY '" + category.getAlias() + "' must contain at least one PHASE"));
|
||||
}
|
||||
|
||||
for(final DqflPhase phase : category.getPhases())
|
||||
{
|
||||
this.validatePhase(phase, category.getAlias(), errors);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Phase-Validierung
|
||||
// ================================================================
|
||||
|
||||
private void validatePhase(
|
||||
final DqflPhase phase,
|
||||
final String categoryAlias,
|
||||
final List<DqflError> errors)
|
||||
{
|
||||
final String ctx = "CATEGORY '" + categoryAlias + "' PHASE " + phase.getPhaseType();
|
||||
|
||||
// SCORING muss gesetzt sein
|
||||
if(phase.getScoring() == null)
|
||||
{
|
||||
errors.add(DqflError.error(ctx + " requires SCORING"));
|
||||
}
|
||||
|
||||
// TEAM_MODE muss gesetzt sein
|
||||
if(phase.getTeamMode() == null)
|
||||
{
|
||||
errors.add(DqflError.error(ctx + " requires TEAM_MODE"));
|
||||
}
|
||||
|
||||
// NORMAL-Phase Regeln
|
||||
if(phase.getPhaseType() == PhaseType.NORMAL)
|
||||
{
|
||||
if(phase.getCycles() == null)
|
||||
{
|
||||
errors.add(DqflError.warning(ctx + " has no CYCLES defined"));
|
||||
}
|
||||
if(phase.getTurnsPerCycle() == null)
|
||||
{
|
||||
errors.add(DqflError.warning(ctx + " has no TURNS_PER_CYCLE defined"));
|
||||
}
|
||||
}
|
||||
|
||||
// BOSS-Phase: TEAM_MODE sollte TWO_VS_TWO sein
|
||||
if(phase.getPhaseType() == PhaseType.BOSS)
|
||||
{
|
||||
if(phase.getTeamMode() == TeamModeType.SOLO)
|
||||
{
|
||||
errors.add(DqflError.warning(
|
||||
ctx + " is BOSS phase but TEAM_MODE is SOLO — typically TWO_VS_TWO"));
|
||||
}
|
||||
|
||||
if(phase.getTeamMode() == TeamModeType.TWO_VS_TWO
|
||||
&& phase.getTeamAssignment() == null)
|
||||
{
|
||||
errors.add(DqflError.error(
|
||||
ctx + " has TEAM_MODE TWO_VS_TWO but no TEAM_ASSIGNMENT"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Alias-Eindeutigkeit (über Objektarten)
|
||||
// ================================================================
|
||||
|
||||
private void validateAliasUniqueness(
|
||||
final DqflDocument document,
|
||||
final List<DqflError> errors)
|
||||
{
|
||||
// SOURCE-Aliase eindeutig (Map-Struktur prüft bereits implizit,
|
||||
// aber bei Duplikaten im Dokument wurde der erste überschrieben)
|
||||
// POOL-Aliase eindeutig (gleiche Map-Logik)
|
||||
|
||||
// Wir prüfen explizit ob Pool-Aliase und Source-Aliase kollidieren (optional, sauber)
|
||||
final Set<String> sourceAliases = document.getSources().keySet();
|
||||
final Set<String> poolAliases = document.getPools().keySet();
|
||||
|
||||
for(final String poolAlias : poolAliases)
|
||||
{
|
||||
if(sourceAliases.contains(poolAlias))
|
||||
{
|
||||
errors.add(DqflError.warning(
|
||||
"POOL alias '" + poolAlias
|
||||
+ "' collides with a SOURCE alias — this may cause confusion"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
411
src/test/java/de/dogfire/dqfl/DqflTest.java
Normal file
411
src/test/java/de/dogfire/dqfl/DqflTest.java
Normal file
@@ -0,0 +1,411 @@
|
||||
|
||||
package de.dogfire.dqfl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import de.dogfire.dqfl.error.DqflParseException;
|
||||
import de.dogfire.dqfl.error.DqflValidationResult;
|
||||
import de.dogfire.dqfl.model.*;
|
||||
|
||||
/**
|
||||
* Tests für die DQFL V1 Library: Parser + Validator.
|
||||
*/
|
||||
class DqflTest
|
||||
{
|
||||
// ================================================================
|
||||
// Parser Tests
|
||||
// ================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("Parser")
|
||||
class ParserTests
|
||||
{
|
||||
@Test
|
||||
@DisplayName("Parst das vollständige Referenzbeispiel aus der Spec")
|
||||
void parseReferenceExample() throws Exception
|
||||
{
|
||||
final DqflDocument doc = loadReferenceDocument();
|
||||
|
||||
assertEquals("V1", doc.getDqflVersion());
|
||||
assertEquals("Dogfire Quizabend", doc.getFlowName());
|
||||
assertEquals(1, doc.getScriptRevision());
|
||||
|
||||
// Sources
|
||||
assertEquals(1, doc.getSources().size());
|
||||
final DqflSource src = doc.getSource("SRC1");
|
||||
assertNotNull(src);
|
||||
assertEquals(SourceType.REST, src.getType());
|
||||
assertEquals("USER_POOL_MAIN", src.getConnection());
|
||||
|
||||
// Pools
|
||||
assertEquals(2, doc.getPools().size());
|
||||
final DqflPool history = doc.getPool("HISTORY");
|
||||
assertNotNull(history);
|
||||
assertEquals("SRC1", history.getSourceAlias());
|
||||
assertEquals("AB12CD340000004711", history.getRemoteId());
|
||||
assertEquals("Geschichte", history.getTitle());
|
||||
|
||||
final DqflPool movies = doc.getPool("MOVIES");
|
||||
assertNotNull(movies);
|
||||
assertEquals("ZX82LM550000009321", movies.getRemoteId());
|
||||
|
||||
// Rounds
|
||||
assertEquals(1, doc.getRounds().size());
|
||||
final DqflRound round = doc.getRounds().get(0);
|
||||
assertEquals("R1", round.getAlias());
|
||||
assertEquals("Rematch", round.getTitle());
|
||||
|
||||
// Categories
|
||||
assertEquals(2, round.getCategories().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Parst Category-Details korrekt")
|
||||
void parseCategoryDetails() throws Exception
|
||||
{
|
||||
final DqflDocument doc = loadReferenceDocument();
|
||||
final DqflCategory c1 = doc.getRounds().get(0).getCategories().get(0);
|
||||
|
||||
assertEquals("C1", c1.getAlias());
|
||||
assertEquals("Geschichte", c1.getTitle());
|
||||
assertEquals("HISTORY", c1.getUsePool());
|
||||
assertEquals(LayoutType.GRID_2D, c1.getLayout());
|
||||
assertEquals(new GridSize(6, 6), c1.getSize());
|
||||
assertEquals(2, c1.getPhases().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Parst NORMAL Phase korrekt")
|
||||
void parseNormalPhase() throws Exception
|
||||
{
|
||||
final DqflDocument doc = loadReferenceDocument();
|
||||
final DqflPhase normal = doc.getRounds().get(0).getCategories().get(0).getPhases().get(0);
|
||||
|
||||
assertEquals(PhaseType.NORMAL, normal.getPhaseType());
|
||||
assertEquals(QuestionSetType.NORMAL, normal.getQuestionSet());
|
||||
assertEquals(3, normal.getCycles());
|
||||
assertEquals(4, normal.getTurnsPerCycle());
|
||||
assertEquals(TurnOrderType.ROTATING_START, normal.getTurnOrder());
|
||||
assertEquals(PickModeType.FREE_UNANSWERED, normal.getPickMode());
|
||||
assertEquals(TeamModeType.SOLO, normal.getTeamMode());
|
||||
assertEquals(ScoringProfile.SOLO_PLUS1_NO_PENALTY, normal.getScoring());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Parst BOSS Phase korrekt")
|
||||
void parseBossPhase() throws Exception
|
||||
{
|
||||
final DqflDocument doc = loadReferenceDocument();
|
||||
final DqflPhase boss = doc.getRounds().get(0).getCategories().get(0).getPhases().get(1);
|
||||
|
||||
assertEquals(PhaseType.BOSS, boss.getPhaseType());
|
||||
assertEquals(QuestionSetType.BOSS, boss.getQuestionSet());
|
||||
assertEquals(TeamModeType.TWO_VS_TWO, boss.getTeamMode());
|
||||
assertEquals(TeamAssignmentType.RANDOM, boss.getTeamAssignment());
|
||||
assertEquals(ScoringProfile.BOSS_2V2_PLUS2_PER_CORRECT_PLAYER, boss.getScoring());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getAllCategoriesFlat liefert alle Kategorien in Dokumentreihenfolge")
|
||||
void flatCategoryList() throws Exception
|
||||
{
|
||||
final DqflDocument doc = loadReferenceDocument();
|
||||
final var flat = doc.getAllCategoriesFlat();
|
||||
|
||||
assertEquals(2, flat.size());
|
||||
assertEquals("C1", flat.get(0).getAlias());
|
||||
assertEquals("C2", flat.get(1).getAlias());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Leerer Input wirft DqflParseException")
|
||||
void emptyInputThrows()
|
||||
{
|
||||
assertThrows(DqflParseException.class, () -> Dqfl.parse(""));
|
||||
assertThrows(DqflParseException.class, () -> Dqfl.parse(" \n \n "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unbekanntes Root-Keyword erzeugt Fehler")
|
||||
void unknownRootKeyword()
|
||||
{
|
||||
final String input = """
|
||||
DQFL_VERSION V1
|
||||
FLOW "Test"
|
||||
BOGUS_KEYWORD value
|
||||
""";
|
||||
|
||||
final DqflParseException ex = assertThrows(DqflParseException.class,
|
||||
() -> Dqfl.parse(input));
|
||||
|
||||
assertTrue(ex.getErrors().stream()
|
||||
.anyMatch(e -> e.getMessage().contains("BOGUS_KEYWORD")));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Validator Tests
|
||||
// ================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("Validator")
|
||||
class ValidatorTests
|
||||
{
|
||||
@Test
|
||||
@DisplayName("Referenzbeispiel besteht die Validierung")
|
||||
void referenceExampleIsValid() throws Exception
|
||||
{
|
||||
final DqflDocument doc = loadReferenceDocument();
|
||||
final DqflValidationResult result = Dqfl.validate(doc);
|
||||
|
||||
assertTrue(result.isValid(),
|
||||
"Reference example should be valid. Errors: " + result.getErrorsOnly());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Fehlender USE_POOL wird erkannt")
|
||||
void missingUsePool() throws Exception
|
||||
{
|
||||
final String input = """
|
||||
DQFL_VERSION V1
|
||||
FLOW "Test"
|
||||
SOURCE SRC1
|
||||
TYPE REST
|
||||
CONNECTION CONN1
|
||||
POOL P1
|
||||
SOURCE SRC1
|
||||
REMOTE_ID "AB12CD340000004711"
|
||||
ROUND R1
|
||||
CATEGORY C1
|
||||
LAYOUT GRID_2D
|
||||
SIZE 6x6
|
||||
PHASE NORMAL
|
||||
TEAM_MODE SOLO
|
||||
SCORING SOLO_PLUS1_NO_PENALTY
|
||||
""";
|
||||
|
||||
final DqflDocument doc = Dqfl.parse(input);
|
||||
final DqflValidationResult result = Dqfl.validate(doc);
|
||||
|
||||
assertFalse(result.isValid());
|
||||
assertTrue(result.getErrorsOnly().stream()
|
||||
.anyMatch(e -> e.getMessage().contains("USE_POOL")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SIZE ohne LAYOUT wird erkannt")
|
||||
void sizeWithoutLayout() throws Exception
|
||||
{
|
||||
final String input = """
|
||||
DQFL_VERSION V1
|
||||
FLOW "Test"
|
||||
SOURCE SRC1
|
||||
TYPE REST
|
||||
CONNECTION CONN1
|
||||
POOL P1
|
||||
SOURCE SRC1
|
||||
REMOTE_ID "AB12CD340000004711"
|
||||
ROUND R1
|
||||
CATEGORY C1
|
||||
USE_POOL P1
|
||||
SIZE 6x6
|
||||
PHASE NORMAL
|
||||
TEAM_MODE SOLO
|
||||
SCORING SOLO_PLUS1_NO_PENALTY
|
||||
""";
|
||||
|
||||
final DqflDocument doc = Dqfl.parse(input);
|
||||
final DqflValidationResult result = Dqfl.validate(doc);
|
||||
|
||||
assertFalse(result.isValid());
|
||||
assertTrue(result.getErrorsOnly().stream()
|
||||
.anyMatch(e -> e.getMessage().contains("SIZE without LAYOUT")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Fehlende PHASE wird erkannt")
|
||||
void missingPhase() throws Exception
|
||||
{
|
||||
final String input = """
|
||||
DQFL_VERSION V1
|
||||
FLOW "Test"
|
||||
SOURCE SRC1
|
||||
TYPE REST
|
||||
CONNECTION CONN1
|
||||
POOL P1
|
||||
SOURCE SRC1
|
||||
REMOTE_ID "AB12CD340000004711"
|
||||
ROUND R1
|
||||
CATEGORY C1
|
||||
USE_POOL P1
|
||||
""";
|
||||
|
||||
final DqflDocument doc = Dqfl.parse(input);
|
||||
final DqflValidationResult result = Dqfl.validate(doc);
|
||||
|
||||
assertFalse(result.isValid());
|
||||
assertTrue(result.getErrorsOnly().stream()
|
||||
.anyMatch(e -> e.getMessage().contains("at least one PHASE")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unbekannter POOL-Verweis wird erkannt")
|
||||
void unknownPoolReference() throws Exception
|
||||
{
|
||||
final String input = """
|
||||
DQFL_VERSION V1
|
||||
FLOW "Test"
|
||||
SOURCE SRC1
|
||||
TYPE REST
|
||||
CONNECTION CONN1
|
||||
ROUND R1
|
||||
CATEGORY C1
|
||||
USE_POOL DOES_NOT_EXIST
|
||||
PHASE NORMAL
|
||||
TEAM_MODE SOLO
|
||||
SCORING SOLO_PLUS1_NO_PENALTY
|
||||
""";
|
||||
|
||||
final DqflDocument doc = Dqfl.parse(input);
|
||||
final DqflValidationResult result = Dqfl.validate(doc);
|
||||
|
||||
assertFalse(result.isValid());
|
||||
assertTrue(result.getErrorsOnly().stream()
|
||||
.anyMatch(e -> e.getMessage().contains("DOES_NOT_EXIST")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("BOSS Phase ohne TEAM_ASSIGNMENT wird erkannt")
|
||||
void bossWithoutTeamAssignment() throws Exception
|
||||
{
|
||||
final String input = """
|
||||
DQFL_VERSION V1
|
||||
FLOW "Test"
|
||||
SOURCE SRC1
|
||||
TYPE REST
|
||||
CONNECTION CONN1
|
||||
POOL P1
|
||||
SOURCE SRC1
|
||||
REMOTE_ID "AB12CD340000004711"
|
||||
ROUND R1
|
||||
CATEGORY C1
|
||||
USE_POOL P1
|
||||
PHASE BOSS
|
||||
QUESTION_SET BOSS
|
||||
TEAM_MODE TWO_VS_TWO
|
||||
SCORING BOSS_2V2_PLUS2_PER_CORRECT_PLAYER
|
||||
""";
|
||||
|
||||
final DqflDocument doc = Dqfl.parse(input);
|
||||
final DqflValidationResult result = Dqfl.validate(doc);
|
||||
|
||||
assertFalse(result.isValid());
|
||||
assertTrue(result.getErrorsOnly().stream()
|
||||
.anyMatch(e -> e.getMessage().contains("TEAM_ASSIGNMENT")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseAndValidate Convenience-Methode funktioniert")
|
||||
void parseAndValidateConvenience() throws Exception
|
||||
{
|
||||
final String input = loadReferenceString();
|
||||
final DqflDocument doc = Dqfl.parseAndValidate(input);
|
||||
|
||||
assertNotNull(doc);
|
||||
assertEquals("V1", doc.getDqflVersion());
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// GridSize Tests
|
||||
// ================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("GridSize")
|
||||
class GridSizeTests
|
||||
{
|
||||
@Test
|
||||
void parseValid()
|
||||
{
|
||||
final GridSize gs = GridSize.parse("6x6");
|
||||
assertNotNull(gs);
|
||||
assertEquals(6, gs.getRows());
|
||||
assertEquals(6, gs.getCols());
|
||||
assertEquals(36, gs.getTotalCells());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseRectangular()
|
||||
{
|
||||
final GridSize gs = GridSize.parse("4x9");
|
||||
assertNotNull(gs);
|
||||
assertEquals(4, gs.getRows());
|
||||
assertEquals(9, gs.getCols());
|
||||
assertEquals(36, gs.getTotalCells());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseInvalid()
|
||||
{
|
||||
assertNull(GridSize.parse("abc"));
|
||||
assertNull(GridSize.parse("6"));
|
||||
assertNull(GridSize.parse(null));
|
||||
assertNull(GridSize.parse(""));
|
||||
assertNull(GridSize.parse("0x6")); // 0 ist nicht positiv
|
||||
}
|
||||
|
||||
@Test
|
||||
void equality()
|
||||
{
|
||||
assertEquals(GridSize.parse("6x6"), GridSize.parse("6x6"));
|
||||
assertNotEquals(GridSize.parse("6x6"), GridSize.parse("4x9"));
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// ScoringProfile Tests
|
||||
// ================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("ScoringProfile")
|
||||
class ScoringProfileTests
|
||||
{
|
||||
@Test
|
||||
void soloPoints()
|
||||
{
|
||||
assertEquals(1, ScoringProfile.SOLO_PLUS1_NO_PENALTY.getPointsCorrect());
|
||||
assertEquals(0, ScoringProfile.SOLO_PLUS1_NO_PENALTY.getPointsWrong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bossPoints()
|
||||
{
|
||||
assertEquals(2, ScoringProfile.BOSS_2V2_PLUS2_PER_CORRECT_PLAYER.getPointsCorrect());
|
||||
assertEquals(0, ScoringProfile.BOSS_2V2_PLUS2_PER_CORRECT_PLAYER.getPointsWrong());
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Helpers
|
||||
// ================================================================
|
||||
|
||||
private static DqflDocument loadReferenceDocument() throws Exception
|
||||
{
|
||||
return Dqfl.parse(loadReferenceString());
|
||||
}
|
||||
|
||||
private static String loadReferenceString() throws Exception
|
||||
{
|
||||
try(final InputStream is = DqflTest.class.getResourceAsStream("/reference-example.dqfl"))
|
||||
{
|
||||
assertNotNull(is, "reference-example.dqfl not found on classpath");
|
||||
return new String(is.readAllBytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/test/resources/reference-example.dqfl
Normal file
56
src/test/resources/reference-example.dqfl
Normal file
@@ -0,0 +1,56 @@
|
||||
DQFL_VERSION V1
|
||||
FLOW "Dogfire Quizabend"
|
||||
SCRIPT_REVISION 1
|
||||
|
||||
SOURCE SRC1
|
||||
TYPE REST
|
||||
CONNECTION USER_POOL_MAIN
|
||||
|
||||
POOL HISTORY
|
||||
SOURCE SRC1
|
||||
REMOTE_ID "AB12CD340000004711"
|
||||
TITLE "Geschichte"
|
||||
|
||||
POOL MOVIES
|
||||
SOURCE SRC1
|
||||
REMOTE_ID "ZX82LM550000009321"
|
||||
TITLE "Filme"
|
||||
|
||||
ROUND R1
|
||||
TITLE "Rematch"
|
||||
CATEGORY C1
|
||||
TITLE "Geschichte"
|
||||
USE_POOL HISTORY
|
||||
LAYOUT GRID_2D
|
||||
SIZE 6x6
|
||||
PHASE NORMAL
|
||||
QUESTION_SET NORMAL
|
||||
CYCLES 3
|
||||
TURNS_PER_CYCLE 4
|
||||
TURN_ORDER ROTATING_START
|
||||
PICK_MODE FREE_UNANSWERED
|
||||
TEAM_MODE SOLO
|
||||
SCORING SOLO_PLUS1_NO_PENALTY
|
||||
PHASE BOSS
|
||||
QUESTION_SET BOSS
|
||||
TEAM_MODE TWO_VS_TWO
|
||||
TEAM_ASSIGNMENT RANDOM
|
||||
SCORING BOSS_2V2_PLUS2_PER_CORRECT_PLAYER
|
||||
CATEGORY C2
|
||||
TITLE "Filme"
|
||||
USE_POOL MOVIES
|
||||
LAYOUT GRID_2D
|
||||
SIZE 6x6
|
||||
PHASE NORMAL
|
||||
QUESTION_SET NORMAL
|
||||
CYCLES 3
|
||||
TURNS_PER_CYCLE 4
|
||||
TURN_ORDER ROTATING_START
|
||||
PICK_MODE FREE_UNANSWERED
|
||||
TEAM_MODE SOLO
|
||||
SCORING SOLO_PLUS1_NO_PENALTY
|
||||
PHASE BOSS
|
||||
QUESTION_SET BOSS
|
||||
TEAM_MODE TWO_VS_TWO
|
||||
TEAM_ASSIGNMENT RANDOM
|
||||
SCORING BOSS_2V2_PLUS2_PER_CORRECT_PLAYER
|
||||
Reference in New Issue
Block a user