First INIT

This commit is contained in:
2026-03-27 13:33:21 +01:00
commit 1b273cae14
31 changed files with 2796 additions and 0 deletions

27
.classpath Normal file
View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="optional" value="true"/>
</attributes>
</classpathentry>
<classpathentry excluding="**" kind="src" output="target/test-classes" path="src/test/resources">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="test" value="true"/>
<attribute name="optional" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/bin/
/target/

23
.project Normal file
View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>DQFL-Library</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.m2e.core.maven2Builder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.m2e.core.maven2Nature</nature>
<nature>org.eclipse.jdt.core.javanature</nature>
</natures>
</projectDescription>

View File

@@ -0,0 +1,2 @@
eclipse.preferences.version=1
encoding/<project>=UTF-8

View File

@@ -0,0 +1,15 @@
eclipse.preferences.version=1
org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled
org.eclipse.jdt.core.compiler.codegen.targetPlatform=17
org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
org.eclipse.jdt.core.compiler.compliance=17
org.eclipse.jdt.core.compiler.debug.lineNumber=generate
org.eclipse.jdt.core.compiler.debug.localVariable=generate
org.eclipse.jdt.core.compiler.debug.sourceFile=generate
org.eclipse.jdt.core.compiler.problem.assertIdentifier=error
org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled
org.eclipse.jdt.core.compiler.problem.enumIdentifier=error
org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning
org.eclipse.jdt.core.compiler.release=enabled
org.eclipse.jdt.core.compiler.source=17

View File

@@ -0,0 +1,4 @@
activeProfiles=
eclipse.preferences.version=1
resolveWorkspaceProjects=true
version=1

54
pom.xml Normal file
View File

@@ -0,0 +1,54 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.dogfire.dqfl</groupId>
<artifactId>dqfl-core</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>DQFL Core Library</name>
<description>Dogfire Quiz Flow Language V1 - Parser, Validator, and Model</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>17</maven.compiler.release>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<java.version>17</java.version>
<junit.version>5.10.2</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<release>${maven.compiler.release}</release>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
</plugin>
</plugins>
</build>
</project>

View 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;
}
}
}

View 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;
}
}

View 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();
}
}

View File

@@ -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();
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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 + "]";
}
}

View 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);
}
}
}

View 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 + "]";
}
}

View 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);
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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 : "");
}
}
}

View 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"));
}
}
}
}

View 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());
}
}
}

View 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