From 8d36ac287c61320d13b1f3cf1e0dd1c224eee63c Mon Sep 17 00:00:00 2001
From: Michael Kramer <m.kramer@mxp.de>
Date: Mon, 25 Mar 2024 16:26:36 +0100
Subject: [PATCH] Re-write test generator with templates, unit tests

---
 .gitignore                                    |   1 +
 .../documentation/generator/README.md         | 126 ++++++++++++
 .../sequence-diagram-second-level.plantuml    |  18 ++
 .../sequence-diagram-second-level.svg         |   1 +
 .../sequence-diagram-top-level.plantuml       |  17 ++
 .../generator/sequence-diagram-top-level.svg  |   1 +
 contribution/generator/README.md              |  23 +--
 contribution/generator/config/services.yaml   |   5 +-
 .../src/Command/CreateTestsCommand.php        |  40 ++--
 contribution/generator/src/Configlet.php      | 103 ++++++++++
 .../src/EnvVarProcessor/Realpath.php          |  24 +++
 contribution/generator/src/TestGenerator.php  |  92 ---------
 .../generator/src/TrackData/CanonicalData.php | 142 +++++++++++++-
 .../src/TrackData/CanonicalData/TestCase.php  |  18 --
 .../generator/src/TrackData/Exercise.php      |  23 ++-
 .../generator/src/TrackData/Group.php         | 109 +++++++++++
 .../generator/src/TrackData/InnerGroup.php    |  45 +++++
 contribution/generator/src/TrackData/Item.php |  18 ++
 .../generator/src/TrackData/ItemFactory.php   |  32 ++++
 .../src/TrackData/MetaConfigFiles.php         |  20 ++
 .../src/TrackData/PracticeExercise.php        | 179 ++++++++----------
 .../generator/src/TrackData/TestCase.php      | 156 +++++++++++++++
 .../generator/src/TrackData/Unknown.php       |  36 ++++
 .../src/TrackData/canonical-data.txt          |  33 ++++
 .../generator/src/TrackData/group.txt         |   2 +
 .../generator/src/TrackData/test-case.txt     |  15 ++
 .../generator/src/TrackData/unknown.txt       |   5 +
 .../TestGeneration/AssertStringOrder.php      |  36 ++++
 .../CanonicalData/CanonicalDataTest.php       | 118 ++++++++++++
 .../fixtures/cases/01-start-to-list-item.txt  |   3 +
 .../fixtures/cases/02-list-item-to-end.txt    |   2 +
 .../CanonicalData/fixtures/cases/input.json   |   8 +
 .../fixtures/cases/list-item.txt              |   1 +
 .../fixtures/exercise/expected.txt            |  33 ++++
 .../fixtures/exercise/input.json              |   6 +
 .../fixtures/many-line-comments/expected.txt  |   4 +
 .../fixtures/many-line-comments/input.json    |  10 +
 .../fixtures/many-unknown-cases/expected.txt  |  17 ++
 .../fixtures/many-unknown-cases/input.json    |  10 +
 .../fixtures/many-unknown-keys/expected.txt   |   5 +
 .../fixtures/many-unknown-keys/input.json     |   7 +
 .../01-solution-class-name-to-end.txt         |   3 +
 .../fixtures/no-cases/input.json              |   9 +
 .../01-start-to-test-class-name.txt           |  20 ++
 .../fixtures/no-comments/input.json           |   9 +
 .../fixtures/no-exercise/expected.txt         |  33 ++++
 .../fixtures/no-exercise/input.json           |   5 +
 .../fixtures/no-object/input.json             |   1 +
 .../no-solution-class-name/input.json         |   4 +
 .../fixtures/no-solution-file-name/input.json |   4 +
 .../fixtures/no-test-class-name/input.json    |   4 +
 .../no-unknown-key/01-start-to-declare.txt    |   3 +
 .../fixtures/no-unknown-key/input.json        |  12 ++
 .../non-varying-parts/01-start-to-unknown.txt |   3 +
 .../02-unknown-to-comments.txt                |   8 +
 .../03-comments-to-test-class-name.txt        |  15 ++
 ...test-class-name-to-solution-class-name.txt |   3 +
 ...ution-class-name-to-solution-file-name.txt |   5 +
 ...ution-file-name-to-solution-class-name.txt |   6 +
 .../07-solution-class-name-to-cases.txt       |   4 +
 ...indented-but-not-closing-folding-marks.txt |   2 +
 ...ndented-but-not-openning-folding-marks.txt |   2 +
 .../non-varying-parts/09-cases-to-end.txt     |   2 +
 .../fixtures/non-varying-parts/input.json     |  16 ++
 .../fixtures/one-line-comments/expected.txt   |   2 +
 .../fixtures/one-line-comments/input.json     |   8 +
 .../fixtures/one-unknown-key/expected.txt     |   5 +
 .../fixtures/one-unknown-key/input.json       |   6 +
 .../fixtures/solution-class-name/input.json   |   5 +
 .../property-type-declaration.txt             |   1 +
 .../subject-instantiation.txt                 |   1 +
 .../fixtures/solution-file-name/expected.txt  |   1 +
 .../fixtures/solution-file-name/input.json    |   5 +
 .../fixtures/test-class-name/expected.txt     |   1 +
 .../fixtures/test-class-name/input.json       |   5 +
 .../tests/TestGeneration/Group/GroupTest.php  | 108 +++++++++++
 .../comments/01-start-to-comments.txt         |   1 +
 .../comments/02-comments-to-tests.txt         |   4 +
 .../fixtures/comments/03-tests-to-end.txt     |   3 +
 .../Group/fixtures/comments/comments.txt      |   2 +
 .../Group/fixtures/comments/input.json        |  17 ++
 .../Group/fixtures/comments/tests.txt         |   1 +
 .../01-start-to-description.txt               |   1 +
 .../02-description-to-comments.txt            |   1 +
 .../03-comments-to-tests.txt                  |   4 +
 .../04-tests-to-end.txt                       |   3 +
 .../description-and-comments/comments.txt     |   2 +
 .../description-and-comments/description.txt  |   1 +
 .../description-and-comments/input.json       |  18 ++
 .../description-and-comments/tests.txt        |   1 +
 .../description/01-start-to-description.txt   |   1 +
 .../description/02-description-to-tests.txt   |   4 +
 .../fixtures/description/03-tests-to-end.txt  |   3 +
 .../fixtures/description/description.txt      |   1 +
 .../Group/fixtures/description/input.json     |  14 ++
 .../Group/fixtures/description/tests.txt      |   1 +
 .../Group/fixtures/empty-cases/expected.txt   |   2 +
 .../Group/fixtures/empty-cases/input.json     |   3 +
 .../one-case-in-cases/01-start-to-tests.txt   |   3 +
 .../one-case-in-cases/02-tests-to-end.txt     |   3 +
 .../fixtures/one-case-in-cases/input.json     |  13 ++
 .../fixtures/one-case-in-cases/tests.txt      |   1 +
 .../fixtures/unknown/01-start-to-unknown.txt  |   4 +
 .../fixtures/unknown/02-unknown-to-tests.txt  |   3 +
 .../fixtures/unknown/03-tests-to-end.txt      |   3 +
 .../Group/fixtures/unknown/input.json         |  14 ++
 .../Group/fixtures/unknown/tests.txt          |   1 +
 .../Group/fixtures/unknown/unknown.txt        |   1 +
 .../InnerGroup/InnerGroupTest.php             | 168 ++++++++++++++++
 .../fixtures/empty-list/expected.txt          |   0
 .../InnerGroup/fixtures/empty-list/input.json |   1 +
 .../01-start-to-first-case.txt                |   2 +
 .../many-mixed-cases/02-last-case-to-end.txt  |   1 +
 .../many-mixed-cases/first-test-case.txt      |   1 +
 .../many-mixed-cases/first-unknown.txt        |   1 +
 .../fixtures/many-mixed-cases/input.json      |  40 ++++
 .../many-mixed-cases/last-test-case.txt       |   1 +
 .../many-mixed-cases/second-test-case.txt     |   1 +
 .../many-mixed-cases/second-unknown.txt       |   1 +
 .../01-start-to-first-test-case.txt           |   2 +
 .../02-last-test-case-to-end.txt              |   1 +
 .../fixtures/many-test-cases/first-test.txt   |   1 +
 .../fixtures/many-test-cases/input.json       |  29 +++
 .../fixtures/many-test-cases/last-test.txt    |   1 +
 .../fixtures/many-test-cases/second-test.txt  |   1 +
 .../01-start-to-first-item.txt                |   2 +
 .../02-last-item-to-end.txt                   |   1 +
 .../many-unknown-cases/first-item.txt         |   1 +
 .../fixtures/many-unknown-cases/input.json    |   5 +
 .../fixtures/many-unknown-cases/last-item.txt |   1 +
 .../many-unknown-cases/second-item.txt        |   1 +
 .../fixtures/one-group/expected.txt           |   3 +
 .../InnerGroup/fixtures/one-group/input.json  |   6 +
 .../fixtures/one-test-case/expected.txt       |   1 +
 .../fixtures/one-test-case/input.json         |  11 ++
 .../fixtures/one-unknown-case/expected.txt    |   5 +
 .../fixtures/one-unknown-case/input.json      |   3 +
 .../ItemFactory/ItemFactoryTest.php           |  62 ++++++
 .../canonical-data-object-maximal/input.json  |  13 ++
 .../canonical-data-object-minimal/input.json  |   5 +
 .../fixtures/empty-array/input.json           |   1 +
 .../fixtures/empty-object/input.json          |   1 +
 .../fixtures/group-object-maximal/input.json  |  19 ++
 .../fixtures/group-object-minimal/input.json  |   3 +
 .../fixtures/non-empty-array/input.json       |   3 +
 .../test-case-object-maximal/input.json       |  10 +
 .../test-case-object-minimal/input.json       |   9 +
 .../tests/TestGeneration/ScenarioFixture.php  |  56 ++++++
 .../TestGeneration/TestCase/TestCaseTest.php  | 121 ++++++++++++
 .../input.json                                |   9 +
 .../method-name.txt                           |   1 +
 .../testdox.txt                               |   1 +
 .../TestCase/fixtures/description/input.json  |   9 +
 .../fixtures/description/method-name.txt      |   1 +
 .../TestCase/fixtures/description/testdox.txt |   1 +
 .../TestCase/fixtures/empty-object/input.json |   1 +
 .../exception-message.txt                     |   1 +
 .../input.json                                |  11 ++
 .../01-property-to-end.txt                    |   2 +
 .../assertion-on-exception.txt                |   2 +
 .../expect-exception-thrown/input.json        |  11 ++
 .../assertion-on-expected.txt                 |   2 +
 .../expect-returned-value/assign-expected.txt |   1 +
 .../fixtures/expect-returned-value/input.json |   9 +
 .../TestCase/fixtures/input/expected.txt      |   3 +
 .../TestCase/fixtures/input/input.json        |   9 +
 .../fixtures/no-description/input.json        |   8 +
 .../TestCase/fixtures/no-expected/input.json  |   8 +
 .../TestCase/fixtures/no-input/input.json     |   6 +
 .../TestCase/fixtures/no-object/input.json    |   1 +
 .../TestCase/fixtures/no-property/input.json  |   8 +
 .../fixtures/no-unknown/01-start-to-uuid.txt  |   2 +
 .../TestCase/fixtures/no-unknown/input.json   |   9 +
 .../TestCase/fixtures/no-uuid/input.json      |   8 +
 .../non-varying-parts/01-start-to-unknown.txt |   3 +
 .../non-varying-parts/02-unknown-to-uuid.txt  |   3 +
 .../non-varying-parts/03-uuid-to-testdox.txt  |   2 +
 .../04-testdox-to-method-name.txt             |   4 +
 .../05-method-name-to-input.txt               |   5 +
 .../06-input-to-expected.txt                  |   4 +
 .../07-expected-to-property.txt               |   3 +
 .../08-property-to-assertion.txt              |   3 +
 .../non-varying-parts/09-assertion-to-end.txt |   2 +
 .../fixtures/non-varying-parts/input.json     |  10 +
 .../TestCase/fixtures/property/expected.txt   |   1 +
 .../TestCase/fixtures/property/input.json     |   9 +
 .../TestCase/fixtures/unknown/expected.txt    |   1 +
 .../TestCase/fixtures/unknown/input.json      |  10 +
 .../TestCase/fixtures/uuid/expected.txt       |   1 +
 .../TestCase/fixtures/uuid/input.json         |   9 +
 .../TestGeneration/Unknown/UnknownTest.php    |  67 +++++++
 .../Unknown/fixtures/any-object/expected.txt  |   2 +
 .../Unknown/fixtures/any-object/input.json    |   9 +
 .../Unknown/fixtures/array/expected.txt       |   1 +
 .../Unknown/fixtures/array/input.json         |   1 +
 .../Unknown/fixtures/bool/expected.txt        |   1 +
 .../Unknown/fixtures/bool/input.json          |   1 +
 .../fixtures/empty-object/expected.txt        |   1 +
 .../Unknown/fixtures/empty-object/input.json  |   1 +
 .../Unknown/fixtures/float/expected.txt       |   1 +
 .../Unknown/fixtures/float/input.json         |   1 +
 .../Unknown/fixtures/int/expected.txt         |   1 +
 .../Unknown/fixtures/int/input.json           |   1 +
 .../non-varying-parts/01-start-to-json.txt    |   4 +
 .../non-varying-parts/02-json-to-end.txt      |   2 +
 .../fixtures/non-varying-parts/input.json     |   1 +
 .../Unknown/fixtures/null/expected.txt        |   1 +
 .../Unknown/fixtures/null/input.json          |   1 +
 .../Unknown/fixtures/string/expected.txt      |   1 +
 .../Unknown/fixtures/string/input.json        |   1 +
 210 files changed, 2624 insertions(+), 263 deletions(-)
 create mode 100644 contribution/documentation/generator/README.md
 create mode 100644 contribution/documentation/generator/sequence-diagram-second-level.plantuml
 create mode 100644 contribution/documentation/generator/sequence-diagram-second-level.svg
 create mode 100644 contribution/documentation/generator/sequence-diagram-top-level.plantuml
 create mode 100644 contribution/documentation/generator/sequence-diagram-top-level.svg
 create mode 100644 contribution/generator/src/Configlet.php
 create mode 100644 contribution/generator/src/EnvVarProcessor/Realpath.php
 delete mode 100644 contribution/generator/src/TestGenerator.php
 delete mode 100644 contribution/generator/src/TrackData/CanonicalData/TestCase.php
 create mode 100644 contribution/generator/src/TrackData/Group.php
 create mode 100644 contribution/generator/src/TrackData/InnerGroup.php
 create mode 100644 contribution/generator/src/TrackData/Item.php
 create mode 100644 contribution/generator/src/TrackData/ItemFactory.php
 create mode 100644 contribution/generator/src/TrackData/MetaConfigFiles.php
 create mode 100644 contribution/generator/src/TrackData/TestCase.php
 create mode 100644 contribution/generator/src/TrackData/Unknown.php
 create mode 100644 contribution/generator/src/TrackData/canonical-data.txt
 create mode 100644 contribution/generator/src/TrackData/group.txt
 create mode 100644 contribution/generator/src/TrackData/test-case.txt
 create mode 100644 contribution/generator/src/TrackData/unknown.txt
 create mode 100644 contribution/generator/tests/TestGeneration/AssertStringOrder.php
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/CanonicalDataTest.php
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/cases/01-start-to-list-item.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/cases/02-list-item-to-end.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/cases/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/cases/list-item.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/exercise/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/exercise/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-line-comments/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-line-comments/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-unknown-cases/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-unknown-cases/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-unknown-keys/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-unknown-keys/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-cases/01-solution-class-name-to-end.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-cases/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-comments/01-start-to-test-class-name.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-comments/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-exercise/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-exercise/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-object/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-solution-class-name/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-solution-file-name/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-test-class-name/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-unknown-key/01-start-to-declare.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-unknown-key/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/01-start-to-unknown.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/02-unknown-to-comments.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/03-comments-to-test-class-name.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/04-test-class-name-to-solution-class-name.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/05-solution-class-name-to-solution-file-name.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/06-solution-file-name-to-solution-class-name.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/07-solution-class-name-to-cases.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/08-cases-indented-but-not-closing-folding-marks.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/08-cases-indented-but-not-openning-folding-marks.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/09-cases-to-end.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/one-line-comments/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/one-line-comments/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/one-unknown-key/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/one-unknown-key/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-class-name/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-class-name/property-type-declaration.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-class-name/subject-instantiation.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-file-name/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-file-name/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/test-class-name/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/CanonicalData/fixtures/test-class-name/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/Group/GroupTest.php
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/comments/01-start-to-comments.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/comments/02-comments-to-tests.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/comments/03-tests-to-end.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/comments/comments.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/comments/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/comments/tests.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/01-start-to-description.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/02-description-to-comments.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/03-comments-to-tests.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/04-tests-to-end.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/comments.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/description.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/tests.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/description/01-start-to-description.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/description/02-description-to-tests.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/description/03-tests-to-end.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/description/description.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/description/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/description/tests.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/empty-cases/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/empty-cases/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/one-case-in-cases/01-start-to-tests.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/one-case-in-cases/02-tests-to-end.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/one-case-in-cases/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/one-case-in-cases/tests.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/unknown/01-start-to-unknown.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/unknown/02-unknown-to-tests.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/unknown/03-tests-to-end.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/unknown/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/unknown/tests.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Group/fixtures/unknown/unknown.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/InnerGroupTest.php
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/empty-list/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/empty-list/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/01-start-to-first-case.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/02-last-case-to-end.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/first-test-case.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/first-unknown.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/last-test-case.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/second-test-case.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/second-unknown.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/01-start-to-first-test-case.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/02-last-test-case-to-end.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/first-test.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/last-test.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/second-test.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/01-start-to-first-item.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/02-last-item-to-end.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/first-item.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/last-item.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/second-item.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-group/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-group/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-test-case/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-test-case/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-unknown-case/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-unknown-case/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/ItemFactory/ItemFactoryTest.php
 create mode 100644 contribution/generator/tests/TestGeneration/ItemFactory/fixtures/canonical-data-object-maximal/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/ItemFactory/fixtures/canonical-data-object-minimal/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/ItemFactory/fixtures/empty-array/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/ItemFactory/fixtures/empty-object/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/ItemFactory/fixtures/group-object-maximal/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/ItemFactory/fixtures/group-object-minimal/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/ItemFactory/fixtures/non-empty-array/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/ItemFactory/fixtures/test-case-object-maximal/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/ItemFactory/fixtures/test-case-object-minimal/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/ScenarioFixture.php
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/TestCaseTest.php
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/description-with-problematic-chars/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/description-with-problematic-chars/method-name.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/description-with-problematic-chars/testdox.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/description/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/description/method-name.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/description/testdox.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/empty-object/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-different-exception-message/exception-message.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-different-exception-message/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-exception-thrown/01-property-to-end.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-exception-thrown/assertion-on-exception.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-exception-thrown/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-returned-value/assertion-on-expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-returned-value/assign-expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-returned-value/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/input/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/input/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/no-description/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/no-expected/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/no-input/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/no-object/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/no-property/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/no-unknown/01-start-to-uuid.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/no-unknown/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/no-uuid/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/01-start-to-unknown.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/02-unknown-to-uuid.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/03-uuid-to-testdox.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/04-testdox-to-method-name.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/05-method-name-to-input.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/06-input-to-expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/07-expected-to-property.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/08-property-to-assertion.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/09-assertion-to-end.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/property/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/property/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/unknown/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/unknown/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/uuid/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/TestCase/fixtures/uuid/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/UnknownTest.php
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/any-object/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/any-object/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/array/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/array/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/bool/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/bool/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/empty-object/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/empty-object/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/float/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/float/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/int/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/int/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/non-varying-parts/01-start-to-json.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/non-varying-parts/02-json-to-end.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/non-varying-parts/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/null/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/null/input.json
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/string/expected.txt
 create mode 100644 contribution/generator/tests/TestGeneration/Unknown/fixtures/string/input.json

diff --git a/.gitignore b/.gitignore
index 8562fb1c..3566ccd6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@ composer.lock
 
 # IDE Files
 .idea
+.vscode
diff --git a/contribution/documentation/generator/README.md b/contribution/documentation/generator/README.md
new file mode 100644
index 00000000..d4b160c9
--- /dev/null
+++ b/contribution/documentation/generator/README.md
@@ -0,0 +1,126 @@
+# PHP code generator for Exercism PHP track exercises
+
+- [Introduction](#introduction)
+- [Architecture](#architecture)
+- [Contribution](#contribution)
+
+## Introduction
+
+This is a simple code generator for practice exercises in the PHP track based on the [Exercism common problem specifications][exercism-problem-specifications].
+
+> Please read and think about the exercise instructions!
+> Many problems require additional test failure messages and useful information to help students solve the exercise.
+> Generating code is not "being done"!
+
+The majority of problems in problem specifications are *function oriented*.
+That means, all input goes into a single function call and no state of an object changes expected results.
+So the generator generates *function oriented* code.
+A fresh instance is created with no constructor arguments in `setUp()` for each test.
+The tests invoke methods with the input and compare actual results with expectations.
+
+If the problem you generate code for requires object orientation, adjust the tests manually (e.g. replace `$this->subject->`).
+
+The next decision to make is: How much freedom of implementation shall students have?
+For practice exercises we usually give maximum freedom of implementation.
+This freedom must be designed into the student facing interface.
+But there are good reasons to limit the freadom of choice.
+Has PHP an idiomatic way to solve such a class of problems?
+Like using an `enum` for certain result types.
+Then you should design that into the interface.
+
+Mentoring is done to guide students towards "recommended" implementations.
+Some exercises do require stricter boundaries, like "Do not use language provided functions to solve this".
+Such restrictions need to be implemented manually.
+
+Another decision required is the amount of prepared student interface code you want.
+The generator only produces the bare minimum, an empty class with a throwing constructor.
+This is a pragmatic choice, it is easy to implement. 🙂
+Adding predefined methods lowers the difficulty of the exercise.
+Testing for type declarations being set and / or testing for type safety raises the difficulty.
+So it is your choice, if you want to do so or not.
+
+Now you have made the basic decisions. Time to use the generator!
+
+- Follow the track README to install track tooling (`configlet`)
+- Run from track root:
+
+  ```shell
+  bin/configlet create --practice-exercise '<slug>'
+  composer -d contribution/generator install
+  contribution/generator/bin/console app:create-tests '<slug>'
+  composer lint:fix
+  vendor/bin/phpunit exercises/practice/'<slug>'/*Test.php
+  ```
+
+- Run `git status` to see all the generated files.
+  These are yours now.
+- Adjust the code as required.
+- Add more information to `.meta/*.append.md`.
+  The generated files `.meta/*.md` are kept in sync with problem specifications.
+- Mark tests not implemented with `include = false` in `tests.toml`.
+- Open a PR to get feedback on your exercise **early**.
+
+## Architecture
+
+The `Exercise` interface supports both types of exercises, `PracticeExercise` and the planned `ConceptExercise`.
+For both exercise types, the test file and the students file are generated from `configlet` generated directory structures and files.
+It is planned to use a `canonical-data.json` similar to the problem specification for the concept exercises, too.
+
+Between the symfony command(s) and the actual test / students file generation is the test boundary `ItemFactory`.
+By this, the integration testing (not yet implemented) needs to test only, that the expected files are generated with some sample text.
+The actual details of test / student file generation can be tested without mocking the filesystem or booting the symfony kernel.
+
+![Top level sequence](sequence-diagram-top-level.svg)
+
+`configlet` is used by `PracticeExercise`, wrapped in an own class.
+This is because the [cached problem specifications][exercism-problem-specifications] is required and `configlet` knows best where to find that.
+The actual path differs depending on the underlying operating system, and we shouldn't copy that from `configlet` sources.
+
+We started with an implementation walking through the raw JSON data and used `nikic/php-parser` to produce PHP code from that.
+This hided the underlying data structures used to construct the varying canonical data sets in the problem specification.
+So we made them explicit:
+
+- `Item`s are all data structures to construct the varying canonical data sets (interface).
+- `ItemFactory` turning raw data into the matching `Item` (object).
+- `Unknown` represents data structures that do not follow a known schema (object).
+- `TestCase`s represent tests with input and expected outcome of the students code (object).
+- `Group`s are sets of `Item`s, that share a common theme and may have some title, explanation and folding section markers (object).
+- `InnerGroup`s are the pure lists of `Item`s to convert to tests (object).
+- `CanonicalData` is the outermost group with the expected extra data for an exercise and an `InnerGroup` with `Item`s (object).
+
+![Second level sequence](sequence-diagram-second-level.svg)
+
+This allows to represent the JSON data as a tree of groups and test cases.
+Every data structure object examines the raw data and produces an instance only, if it can handle that structure.
+The tree is built using `ItemFactory`, which starts with the most specific data structure `CanonicalData` and goes out towards `Unknown` until an instance is made from the raw data given.
+Having all unknown data catched into an `Unknown` allows rendering any structure found into the output files.
+
+Such a tree structure looks like doing the code production with the "visitor" pattern.
+But that pattern comes with a huge cost of complexity and should only be used, if the tree will have multiple visitors.
+So the simple way of iterating directly over the tree to produce the output from the data objects is prefered.
+
+Also it is tempting to implement a transformation logic "data -> abstract syntax tree", which we had with `nikic/php-parser`.
+But this also adds a huge amount of complexity for no real win - the AST would only be used for pre-defined code output.
+Predefined code snippets - code templates with placeholders for the data given - are much simpler and allow direct control of formatting.
+Templates also don't require special knowledge about the AST library used.
+Simply edit the template using IDEs and other tools like any other file.
+For our purpose of producing code, that **should be** verified and corrected by humans, templates are the best solution.
+
+[exercism-problem-specifications]: https://github.com/exercism/problem-specifications/
+
+## Contribution
+
+To get ready to contribute to the generator, run these commands:
+
+```shell
+cd contribution/generator
+composer install
+vendor/bin/phpunit
+```
+
+- Add unit tests for changes to `TrackData` classes
+- Document changes in [Introduction](#introduction) and [Architecture](#architecture)
+
+### Architecture Diagrams
+
+Use [PlantUML](http:://plantuml.com/) to add / modify diagrams.
diff --git a/contribution/documentation/generator/sequence-diagram-second-level.plantuml b/contribution/documentation/generator/sequence-diagram-second-level.plantuml
new file mode 100644
index 00000000..9e677980
--- /dev/null
+++ b/contribution/documentation/generator/sequence-diagram-second-level.plantuml
@@ -0,0 +1,18 @@
+@startuml
+participant Exercise as exercise
+participant CanonicalData as data
+participant InnerGroup as group
+participant Item as item
+
+exercise -> data : ItemFactory::from($data)
+data -> group : ItemFactory::from($data)
+group -> group : ItemFactory::from($data)
+group -> item : ItemFactory::from($data)
+
+exercise -> data : renderTestCode()
+data -> group : renderTestCode()
+group -> group : renderTestCode()
+group -> item : renderTestCode()
+
+exercise -> data : renderSolutionCode()
+@enduml
\ No newline at end of file
diff --git a/contribution/documentation/generator/sequence-diagram-second-level.svg b/contribution/documentation/generator/sequence-diagram-second-level.svg
new file mode 100644
index 00000000..3b351cd6
--- /dev/null
+++ b/contribution/documentation/generator/sequence-diagram-second-level.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="us-ascii" standalone="no"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" contentStyleType="text/css" height="379px" preserveAspectRatio="none" style="width:639px;height:379px;background:#FFFFFF;" version="1.1" viewBox="0 0 639 379" width="639px" zoomAndPan="magnify"><defs/><g><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5.0,5.0;" x1="42" x2="42" y1="36.2969" y2="344.4922"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5.0,5.0;" x1="230.5" x2="230.5" y1="36.2969" y2="344.4922"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5.0,5.0;" x1="419.5" x2="419.5" y1="36.2969" y2="344.4922"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5.0,5.0;" x1="609" x2="609" y1="36.2969" y2="344.4922"/><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="74" x="5" y="5"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="60" x="12" y="24.9951">Exercise</text><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="74" x="5" y="343.4922"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="60" x="12" y="363.4873">Exercise</text><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="117" x="172.5" y="5"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="103" x="179.5" y="24.9951">CanonicalData</text><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="117" x="172.5" y="343.4922"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="103" x="179.5" y="363.4873">CanonicalData</text><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="95" x="372.5" y="5"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="81" x="379.5" y="24.9951">InnerGroup</text><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="95" x="372.5" y="343.4922"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="81" x="379.5" y="363.4873">InnerGroup</text><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="48" x="585" y="5"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="34" x="592" y="24.9951">Item</text><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="48" x="585" y="343.4922"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="34" x="592" y="363.4873">Item</text><polygon fill="#181818" points="219,63.4297,229,67.4297,219,71.4297,223,67.4297" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="42" x2="225" y1="67.4297" y2="67.4297"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="165" x="49" y="62.3638">ItemFactory::from($data)</text><polygon fill="#181818" points="408,92.5625,418,96.5625,408,100.5625,412,96.5625" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="231" x2="414" y1="96.5625" y2="96.5625"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="165" x="238" y="91.4966">ItemFactory::from($data)</text><line style="stroke:#181818;stroke-width:1.0;" x1="420" x2="462" y1="125.6953" y2="125.6953"/><line style="stroke:#181818;stroke-width:1.0;" x1="462" x2="462" y1="125.6953" y2="138.6953"/><line style="stroke:#181818;stroke-width:1.0;" x1="421" x2="462" y1="138.6953" y2="138.6953"/><polygon fill="#181818" points="431,134.6953,421,138.6953,431,142.6953,427,138.6953" style="stroke:#181818;stroke-width:1.0;"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="165" x="427" y="120.6294">ItemFactory::from($data)</text><polygon fill="#181818" points="597,163.8281,607,167.8281,597,171.8281,601,167.8281" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="420" x2="603" y1="167.8281" y2="167.8281"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="165" x="427" y="162.7622">ItemFactory::from($data)</text><polygon fill="#181818" points="219,192.9609,229,196.9609,219,200.9609,223,196.9609" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="42" x2="225" y1="196.9609" y2="196.9609"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="113" x="49" y="191.895">renderTestCode()</text><polygon fill="#181818" points="408,222.0938,418,226.0938,408,230.0938,412,226.0938" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="231" x2="414" y1="226.0938" y2="226.0938"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="113" x="238" y="221.0278">renderTestCode()</text><line style="stroke:#181818;stroke-width:1.0;" x1="420" x2="462" y1="255.2266" y2="255.2266"/><line style="stroke:#181818;stroke-width:1.0;" x1="462" x2="462" y1="255.2266" y2="268.2266"/><line style="stroke:#181818;stroke-width:1.0;" x1="421" x2="462" y1="268.2266" y2="268.2266"/><polygon fill="#181818" points="431,264.2266,421,268.2266,431,272.2266,427,268.2266" style="stroke:#181818;stroke-width:1.0;"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="113" x="427" y="250.1606">renderTestCode()</text><polygon fill="#181818" points="597,293.3594,607,297.3594,597,301.3594,601,297.3594" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="420" x2="603" y1="297.3594" y2="297.3594"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="113" x="427" y="292.2935">renderTestCode()</text><polygon fill="#181818" points="219,322.4922,229,326.4922,219,330.4922,223,326.4922" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="42" x2="225" y1="326.4922" y2="326.4922"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="136" x="49" y="321.4263">renderSolutionCode()</text><!--SRC=[bSuz3i8m30NWFQV8m506Bj21fVo86IvWfGP5Qkt8TIMuFKrQ9AhGIaosz3xPBu2eTpu0gJq-KPplq49dSTfD-08L49Dtq1n08Qfwc3Dn8K8v2_SXne-up7F5DYP-cCPydjZTFfsoPG8dSChoAikxS5jiOh8rAHzSkha6H_2NZ6tMuA-YWbIZtB3JYciiye8Po8ejYwbG1fQBNBdfrJEDy0q0]--></g></svg>
\ No newline at end of file
diff --git a/contribution/documentation/generator/sequence-diagram-top-level.plantuml b/contribution/documentation/generator/sequence-diagram-top-level.plantuml
new file mode 100644
index 00000000..150f3aa1
--- /dev/null
+++ b/contribution/documentation/generator/sequence-diagram-top-level.plantuml
@@ -0,0 +1,17 @@
+@startuml
+actor Developer as dev
+participant Command as command
+participant Exercise as exercise
+participant CanonicalData as data
+
+dev -> command : create-exercise $slug
+command -> exercise : create($slug)
+exercise -> exercise : loadData($slug)
+exercise -> data : ItemFactory::from($data)
+command -> exercise : testFileName()
+command -> exercise : testFileContent()
+exercise -> data : renderTestCode()
+command -> exercise : solutionFileName()
+command -> exercise : solutionFileContent()
+exercise -> data : renderSolutionCode()
+@enduml
diff --git a/contribution/documentation/generator/sequence-diagram-top-level.svg b/contribution/documentation/generator/sequence-diagram-top-level.svg
new file mode 100644
index 00000000..701d4a8c
--- /dev/null
+++ b/contribution/documentation/generator/sequence-diagram-top-level.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="us-ascii" standalone="no"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" contentStyleType="text/css" height="486px" preserveAspectRatio="none" style="width:617px;height:486px;background:#FFFFFF;" version="1.1" viewBox="0 0 617 486" width="617px" zoomAndPan="magnify"><defs/><g><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5.0,5.0;" x1="45" x2="45" y1="81.2969" y2="405.625"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5.0,5.0;" x1="207" x2="207" y1="81.2969" y2="405.625"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5.0,5.0;" x1="363.5" x2="363.5" y1="81.2969" y2="405.625"/><line style="stroke:#181818;stroke-width:0.5;stroke-dasharray:5.0,5.0;" x1="552" x2="552" y1="81.2969" y2="405.625"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="75" x="5" y="77.9951">Developer</text><ellipse cx="45.5" cy="13.5" fill="#E2E2F0" rx="8" ry="8" style="stroke:#181818;stroke-width:0.5;"/><path d="M45.5,21.5 L45.5,48.5 M32.5,29.5 L58.5,29.5 M45.5,48.5 L32.5,63.5 M45.5,48.5 L58.5,63.5 " fill="none" style="stroke:#181818;stroke-width:0.5;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="75" x="5" y="417.6201">Developer</text><ellipse cx="45.5" cy="429.4219" fill="#E2E2F0" rx="8" ry="8" style="stroke:#181818;stroke-width:0.5;"/><path d="M45.5,437.4219 L45.5,464.4219 M32.5,445.4219 L58.5,445.4219 M45.5,464.4219 L32.5,479.4219 M45.5,464.4219 L58.5,479.4219 " fill="none" style="stroke:#181818;stroke-width:0.5;"/><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="87" x="164" y="50"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="73" x="171" y="69.9951">Command</text><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="87" x="164" y="404.625"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="73" x="171" y="424.6201">Command</text><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="74" x="326.5" y="50"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="60" x="333.5" y="69.9951">Exercise</text><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="74" x="326.5" y="404.625"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="60" x="333.5" y="424.6201">Exercise</text><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="117" x="494" y="50"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="103" x="501" y="69.9951">CanonicalData</text><rect fill="#E2E2F0" height="30.2969" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="117" x="494" y="404.625"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="103" x="501" y="424.6201">CanonicalData</text><polygon fill="#181818" points="195.5,108.4297,205.5,112.4297,195.5,116.4297,199.5,112.4297" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="45.5" x2="201.5" y1="112.4297" y2="112.4297"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="138" x="52.5" y="107.3638">create-exercise $slug</text><polygon fill="#181818" points="351.5,137.5625,361.5,141.5625,351.5,145.5625,355.5,141.5625" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="207.5" x2="357.5" y1="141.5625" y2="141.5625"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="85" x="214.5" y="136.4966">create($slug)</text><line style="stroke:#181818;stroke-width:1.0;" x1="363.5" x2="405.5" y1="170.6953" y2="170.6953"/><line style="stroke:#181818;stroke-width:1.0;" x1="405.5" x2="405.5" y1="170.6953" y2="183.6953"/><line style="stroke:#181818;stroke-width:1.0;" x1="364.5" x2="405.5" y1="183.6953" y2="183.6953"/><polygon fill="#181818" points="374.5,179.6953,364.5,183.6953,374.5,187.6953,370.5,183.6953" style="stroke:#181818;stroke-width:1.0;"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="102" x="370.5" y="165.6294">loadData($slug)</text><polygon fill="#181818" points="540.5,208.8281,550.5,212.8281,540.5,216.8281,544.5,212.8281" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="363.5" x2="546.5" y1="212.8281" y2="212.8281"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="165" x="370.5" y="207.7622">ItemFactory::from($data)</text><polygon fill="#181818" points="351.5,237.9609,361.5,241.9609,351.5,245.9609,355.5,241.9609" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="207.5" x2="357.5" y1="241.9609" y2="241.9609"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="94" x="214.5" y="236.895">testFileName()</text><polygon fill="#181818" points="351.5,267.0938,361.5,271.0938,351.5,275.0938,355.5,271.0938" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="207.5" x2="357.5" y1="271.0938" y2="271.0938"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="107" x="214.5" y="266.0278">testFileContent()</text><polygon fill="#181818" points="540.5,296.2266,550.5,300.2266,540.5,304.2266,544.5,300.2266" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="363.5" x2="546.5" y1="300.2266" y2="300.2266"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="113" x="370.5" y="295.1606">renderTestCode()</text><polygon fill="#181818" points="351.5,325.3594,361.5,329.3594,351.5,333.3594,355.5,329.3594" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="207.5" x2="357.5" y1="329.3594" y2="329.3594"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="119" x="214.5" y="324.2935">solutionFileName()</text><polygon fill="#181818" points="351.5,354.4922,361.5,358.4922,351.5,362.4922,355.5,358.4922" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="207.5" x2="357.5" y1="358.4922" y2="358.4922"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="132" x="214.5" y="353.4263">solutionFileContent()</text><polygon fill="#181818" points="540.5,383.625,550.5,387.625,540.5,391.625,544.5,387.625" style="stroke:#181818;stroke-width:1.0;"/><line style="stroke:#181818;stroke-width:1.0;" x1="363.5" x2="546.5" y1="387.625" y2="387.625"/><text fill="#000000" font-family="sans-serif" font-size="13" lengthAdjust="spacing" textLength="136" x="370.5" y="382.5591">renderSolutionCode()</text><!--SRC=[ZP2n3i8W441tleB10OV-G0UNQXCN5_s12vo61BW6het-lL35s4IZ4vUynpq2QAOejpYXem6ZX2GDJcA0o5RR0GBBZho7O0hIZt61TrUCsYOi79_pyZu42bQ3smB37CYd43aZcqrTALkf8m9ZKtV8LNBZHLIUrHUfhfgLjNY1fUG8J6b-qieRih9dz3sKFxYrxJcILwj2rb-oZ8bxw_007jKlgQF065Xz34SC1kCfgnsPxxiIkP4jXP_HT_6FyF6fF-9t]--></g></svg>
\ No newline at end of file
diff --git a/contribution/generator/README.md b/contribution/generator/README.md
index 09fa6828..36da08ca 100644
--- a/contribution/generator/README.md
+++ b/contribution/generator/README.md
@@ -1,20 +1,7 @@
-# Auto Creating of tests for Exercism PHP Track
+# PHP code generator for Exercism PHP track exercises
 
-This is a small poc on how we could auto generate tests for the PHP track based on the https://github.com/exercism/problem-specifications/.
+See the documentation here:
 
-How to test it:
-
-```
-git clone https://github.com/tomasnorre/exercism-tests-generation.git
-cd exercism-tests-generation
-composer install
-bin/console app:create-tests
-vendor/bin/phpunit src/Command/NucleotideCountTest.php
-```
-
-If you now make a `git status` you will see that the `src/Command/NucleotideCountTest.php` and you can now inspect the auto generated tests.
-
-It's all based on the `nikic/php-parser` and the https://github.com/exercism/problem-specifications/ repository, I have made a local copy of that on file
-for now to spare the http-requests.
-
-Let me know what you think.
+- [Introduction](../documentation/generator/README.md#introduction)
+- [Architecture](../documentation/generator/README.md#architecture)
+- [Contribution](../documentation/generator/README.md#contribution)
diff --git a/contribution/generator/config/services.yaml b/contribution/generator/config/services.yaml
index ba14e9fa..db674926 100644
--- a/contribution/generator/config/services.yaml
+++ b/contribution/generator/config/services.yaml
@@ -4,6 +4,9 @@
 # Put parameters here that don't need to change on each machine where the app is deployed
 # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
 parameters:
+    app.env_track_root: '%env(TRACK_ROOT)%'
+    app.default_track_root: '%kernel.project_dir%/../..'
+    app.track_root: '%env(realpath:default:app.default_track_root:env_track_root)%'
 
 services:
     # default configuration for services in *this* file
@@ -11,7 +14,7 @@ services:
         autowire: true      # Automatically injects dependencies in your services.
         autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
         bind:
-            $projectDir: '%kernel.project_dir%'
+            $trackRoot: '%app.track_root%'
 
     # makes classes in src/ available to be used as services
     # this creates a service per class whose id is the fully-qualified class name
diff --git a/contribution/generator/src/Command/CreateTestsCommand.php b/contribution/generator/src/Command/CreateTestsCommand.php
index ae9604f4..558e6f4d 100644
--- a/contribution/generator/src/Command/CreateTestsCommand.php
+++ b/contribution/generator/src/Command/CreateTestsCommand.php
@@ -4,8 +4,7 @@
 
 namespace App\Command;
 
-use App\TestGenerator;
-use App\TrackData\PracticeExercise;
+use App\TrackData\Exercise;
 use Symfony\Component\Console\Attribute\AsCommand;
 use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Input\InputArgument;
@@ -19,13 +18,9 @@
 )]
 class CreateTestsCommand extends Command
 {
-    private string $trackRoot;
-
     public function __construct(
-        private string $projectDir,
+        private Exercise $exercise,
     ) {
-        $this->trackRoot = realpath($projectDir . '/../..');
-
         parent::__construct();
     }
 
@@ -37,35 +32,22 @@ protected function configure(): void
     protected function execute(InputInterface $input, OutputInterface $output): int
     {
         $exerciseSlug = $input->getArgument('exercise');
-        $exercise = new PracticeExercise(
-            $this->trackRoot,
-            $exerciseSlug,
-        );
-        $testGenerator = new TestGenerator();
+        $this->exercise->forSlug($exerciseSlug);
 
         $io = new SymfonyStyle($input, $output);
-        $io->writeln('Generating tests for ' . $exerciseSlug . ' in ' . $exercise->pathToExercise());
+        $io->writeln('Generating tests for ' . $exerciseSlug . ' in ' . $this->exercise->pathToExercise());
 
         \file_put_contents(
-            // TODO: Make '$exercise->pathToTestFile()'
-            $exercise->pathToExercise()
-                . '/'
-                . $this->inPascalCase($exerciseSlug)
-                . 'Test.php',
-            $testGenerator->createTestsFor(
-                $exercise->canonicalData(),
-                $this->inPascalCase($exerciseSlug)
-            ),
+            $this->exercise->pathToTestFile(),
+            $this->exercise->testFileContent(),
+        );
+
+        \file_put_contents(
+            $this->exercise->pathToSolutionFile(),
+            $this->exercise->solutionFileContent(),
         );
-        // TODO: Make '$exercise->pathToStudentsFile()'
-        // TODO: Make '$testGenerator->studentsFileFor()'
 
         $io->success('Generating Tests - Finished');
         return Command::SUCCESS;
     }
-
-    private function inPascalCase(string $text): string
-    {
-        return \str_replace(" ", "", \ucwords(\str_replace("-", " ", $text)));
-    }
 }
diff --git a/contribution/generator/src/Configlet.php b/contribution/generator/src/Configlet.php
new file mode 100644
index 00000000..349c79e9
--- /dev/null
+++ b/contribution/generator/src/Configlet.php
@@ -0,0 +1,103 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App;
+
+use RuntimeException;
+
+class Configlet
+{
+    private string $pathToConfiglet = '';
+
+    public function __construct(
+        private string $trackRoot,
+    ) {
+        $this->pathToConfiglet = $trackRoot . '/bin/configlet';
+    }
+
+    public function pathToCanonicalData(string $slug): string
+    {
+        $this->ensureConfigletCanBeUsed();
+        $canonicalData = $this->cachedCanonicalData($slug);
+        $this->ensureCanonicalDataCanBeUsed($canonicalData);
+
+        return $canonicalData;
+    }
+
+    private function ensureConfigletCanBeUsed(): void
+    {
+        if (
+            !(
+                \is_executable($this->pathToConfiglet)
+                && \is_file($this->pathToConfiglet)
+            )
+        ) {
+            throw new RuntimeException(
+                'configlet not found. Run the generator from track root.'
+                . ' Fetch configlet and create exercise with configlet first!'
+            );
+        }
+    }
+
+    private function cachedCanonicalData(string $slug): string
+    {
+        return \sprintf(
+            '%1$s/exercises/%2$s/canonical-data.json',
+            $this->pathToConfigletCache(),
+            $slug
+        );
+    }
+
+    private function ensureCanonicalDataCanBeUsed(string $canonicalData): void
+    {
+        if (
+            !(
+                \is_readable($canonicalData)
+                && \is_file($canonicalData)
+            )
+        ) {
+            throw new RuntimeException(
+                'Cannot read "configlet" provided cached canonical data from '
+                . $canonicalData
+                . '. Maybe the exercise has no canonical data?'
+                . ' Or check exercise slug and access rights!'
+            );
+        }
+    }
+
+    private function pathToConfigletCache(): string
+    {
+        /*
+        When running configlet with detailed output (-v d) and a command that
+        requires problem specification data (e.g. info), it prints the location
+        of the cache as the first line. To avoid an HTTP call, use the offline
+        mode (-o).
+
+        Pipe the output through 'head' to get the first line only, then 'cut'
+        the 5th field to get the path only.
+
+        configlet may fail when there is no cached data (offline mode), which
+        tells us, that the exercise hasn't been generated before (the cache is
+        required for that, too). So BASH must use `-eo pipefail` to get the
+        failure code back.
+        */
+        $command = 'bash -c \'set -eo pipefail; '
+            . $this->pathToConfiglet
+            . ' -v d -t '
+            . $this->trackRoot
+            . ' info -o | head -1 | cut -d " " -f 5\''
+            ;
+        $resultCode = 1;
+
+        $configletCache = \exec(command: $command, result_code: $resultCode);
+        if ($configletCache === false || $resultCode !== 0) {
+            throw new RuntimeException(
+                '"configlet" could not provide cached canonical data.'
+                . ' Create exercise with configlet first!'
+            );
+        }
+
+        return $configletCache;
+    }
+}
diff --git a/contribution/generator/src/EnvVarProcessor/Realpath.php b/contribution/generator/src/EnvVarProcessor/Realpath.php
new file mode 100644
index 00000000..1cde4245
--- /dev/null
+++ b/contribution/generator/src/EnvVarProcessor/Realpath.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\EnvVarProcessor;
+
+use Symfony\Component\DependencyInjection\EnvVarProcessorInterface;
+
+class Realpath implements EnvVarProcessorInterface
+{
+    public function getEnv(string $prefix, string $name, \Closure $getEnv): string
+    {
+        $env = $getEnv($name);
+
+        return \realpath($env);
+    }
+
+    public static function getProvidedTypes(): array
+    {
+        return [
+            'realpath' => 'string',
+        ];
+    }
+}
diff --git a/contribution/generator/src/TestGenerator.php b/contribution/generator/src/TestGenerator.php
deleted file mode 100644
index e65ea06f..00000000
--- a/contribution/generator/src/TestGenerator.php
+++ /dev/null
@@ -1,92 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace App;
-
-use App\TrackData\CanonicalData;
-use PhpParser\BuilderFactory;
-use PhpParser\Node;
-use PhpParser\Node\Stmt\Namespace_;
-use PhpParser\PrettyPrinter;
-use PHPUnit\Framework\TestCase;
-
-class TestGenerator
-{
-    private BuilderFactory $builderFactory;
-
-    public function createTestsFor(
-        CanonicalData $canonicalData,
-        string $exerciseClass
-    ): string {
-        $this->builderFactory = new BuilderFactory();
-
-        $class = $this->builderFactory->class(
-            $exerciseClass . "Test"
-        )->makeFinal()->extend('TestCase');
-        $class->setDocComment(
-            "/**\n * " . implode("\n * ", $canonicalData->comments) . "\n */"
-        );
-
-        // Include Setup Method
-        $methodSetup = 'setUpBeforeClass';
-        $method = $this->builderFactory->method($methodSetup)
-            ->makePublic()
-            ->makeStatic()
-            ->setReturnType('void')
-            ->addStmt(
-                $this->builderFactory->funcCall(
-                    "require_once",
-                    [$exerciseClass . ".php"]
-                ),
-            );
-
-        $class->addStmt($method);
-
-        foreach ($canonicalData->testCases as $case) {
-            // Generate a method for each test case
-            $description = \ucfirst($case->description);
-            $methodName = ucfirst(str_replace('-', ' ', $description));
-            $methodName = 'test' . str_replace(' ', '', ucwords($methodName));
-
-            // $exceptionClassName = new Node\Name\FullyQualified('Exception');
-            $method = $this->builderFactory->method($methodName)
-                ->makePublic()
-                ->setReturnType('void')
-                ->setDocComment("/**\n * uuid: {$case->uuid}\n * @testdox {$description}\n */")
-                ->addStmt(
-                    $this->builderFactory->funcCall(
-                        '$this->markTestIncomplete',
-                        [ 'This test has not been implemented yet.' ],
-                    )
-                )
-                ;
-            // if (isset($case->expected->error)) {
-            //     $method->addStmt(
-            //         $this->builderFactory->funcCall('$this->expectException',
-            //         [new Node\Arg(new Node\Expr\ClassConstFetch($exceptionClassName, 'class'))]
-            //         )
-            //     )
-            //     ->addStmt($this->builderFactory->funcCall($case->property, [$case->input->strand ?? 'unknown']))
-            //     ;
-            // } else {
-            //     $method->addStmt(
-            //         $this->builderFactory->funcCall('$this->assertEquals', [
-            //             $case->expected,
-            //             $this->builderFactory->funcCall($case->property, [$case->input->strand ?? 'unknown'])
-            //         ])
-            //     );
-            // }
-
-            $class->addStmt($method);
-        }
-
-        $namespace = new Namespace_(new Node\Name('Tests'));
-        $namespace->stmts[] = $this->builderFactory->use(TestCase::class)->getNode();
-        $namespace->stmts[] = $class->getNode();
-
-        $printer = new PrettyPrinter\Standard();
-
-        return $printer->prettyPrintFile([$namespace]) . PHP_EOL;
-    }
-}
diff --git a/contribution/generator/src/TrackData/CanonicalData.php b/contribution/generator/src/TrackData/CanonicalData.php
index cedcee7a..f2210b47 100644
--- a/contribution/generator/src/TrackData/CanonicalData.php
+++ b/contribution/generator/src/TrackData/CanonicalData.php
@@ -4,18 +4,148 @@
 
 namespace App\TrackData;
 
-use App\TrackData\CanonicalData\TestCase;
+use App\TrackData\InnerGroup;
+use App\TrackData\Item;
 
-class CanonicalData
+class CanonicalData implements Item
 {
     /**
-     * @param TestCase[] $testCases
+     * PHP_EOL is CRLF on Windows, we always want LF
+     * @see https://www.php.net/manual/en/reserved.constants.php#constant.php-eol
+     */
+    private const LF = "\n";
+
+    /**
      * @param string[] $comments
      */
     public function __construct(
-        public string $exercise,
-        public array $testCases = [],
-        public array $comments = [],
+        private string $testClassName,
+        private string $solutionFileName,
+        private string $solutionClassName,
+        private InnerGroup $cases,
+        private array $comments = [],
+        private ?object $unknown = null,
     ) {
     }
+
+    public static function from(mixed $rawData): ?static
+    {
+        if (!\is_object($rawData))
+            return null;
+
+        $requiredProperties = [
+            'testClassName',
+            'solutionFileName',
+            'solutionClassName',
+        ];
+        $actualProperties = \array_keys(\get_object_vars($rawData));
+        $requiredData = [];
+        foreach ($requiredProperties as $requiredProperty) {
+            if (!(
+                \in_array($requiredProperty, $actualProperties)
+                && \is_string($rawData->{$requiredProperty})
+            )) {
+                return null;
+            }
+            $requiredData[$requiredProperty] = $rawData->{$requiredProperty};
+            unset($rawData->{$requiredProperty});
+        }
+
+        $comments = $rawData->comments ?? [];
+        unset($rawData->comments);
+
+        // Ignore "exercise" key (not required)
+        unset($rawData->exercise);
+
+        $cases = InnerGroup::from($rawData->cases ?? []);
+        unset($rawData->cases);
+
+        return new static(
+            ...$requiredData,
+            cases: $cases,
+            comments: $comments,
+            unknown: empty(\get_object_vars($rawData)) ? null : $rawData,
+        );
+    }
+
+    public function renderPhpCode(): string
+    {
+        return \sprintf(
+            $this->template(),
+            $this->renderUnknownData(),
+            $this->renderTests(),
+            $this->renderComments(),
+            $this->testClassName,
+            $this->solutionFileName,
+            $this->solutionClassName,
+        );
+    }
+
+    /**
+     * %1$s Unknow data
+     * %2$s Pre-rendered list of tests
+     * %3$s Comments for DocBlock
+     * %4$s Test class name
+     * %5$s Solution file name
+     * %6$s Solution class name
+     */
+    private function template(): string
+    {
+        return \file_get_contents(__DIR__ . '/canonical-data.txt');
+    }
+
+    private function renderUnknownData(): string
+    {
+        if ($this->unknown === null)
+            return '';
+        return $this->asMultiLineComment([\json_encode($this->unknown)]);
+    }
+
+    private function renderTests(): string
+    {
+        $tests = $this->cases->renderPhpCode();
+
+        return empty($tests) ? '' : $this->indent($tests) . self::LF;
+    }
+
+    private function renderComments(): string
+    {
+        return empty($this->comments) ? '' : $this->forBlockComment([...$this->comments, '', '']);
+    }
+
+    private function forBlockComment(array $lines): string
+    {
+        return \implode(self::LF . ' * ', $lines);
+    }
+
+    private function asMultiLineComment(array $lines): string
+    {
+        return self::LF
+            . '/* Unknown data:' . self::LF
+            . ' * ' . implode(self::LF . ' * ', $lines) . self::LF
+            . ' */' . self::LF
+            ;
+    }
+
+    private function indent(string $lines): string
+    {
+        $toUnindent = [
+            '    // {{{',
+            '    // }}}',
+        ];
+        $unindented = [
+            '// {{{',
+            '// }}}',
+        ];
+
+        $indent = '    ';
+        $indentedLines =
+            $indent
+            . \implode(self::LF . $indent, \explode(self::LF, $lines))
+            ;
+
+        $indentedLines = \str_replace($toUnindent, $unindented, $indentedLines);
+
+        return $indentedLines;
+    }
 }
diff --git a/contribution/generator/src/TrackData/CanonicalData/TestCase.php b/contribution/generator/src/TrackData/CanonicalData/TestCase.php
deleted file mode 100644
index 3b29ec40..00000000
--- a/contribution/generator/src/TrackData/CanonicalData/TestCase.php
+++ /dev/null
@@ -1,18 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace App\TrackData\CanonicalData;
-
-class TestCase
-{
-    public function __construct(
-        public string $uuid,
-        public string $description,
-        public string $property,
-        public mixed $input,
-        public mixed $expected,
-        public array $comments = [],
-    ) {
-    }
-}
diff --git a/contribution/generator/src/TrackData/Exercise.php b/contribution/generator/src/TrackData/Exercise.php
index 1c3f6bb1..a2fa8e3c 100644
--- a/contribution/generator/src/TrackData/Exercise.php
+++ b/contribution/generator/src/TrackData/Exercise.php
@@ -4,20 +4,35 @@
 
 namespace App\TrackData;
 
+use App\TrackData\ItemFactory;
+
 interface Exercise
 {
     /**
      * @param string $trackRoot  The absolute location of the track tree
-     * @param string $exerciseSlug  The slug of this exercise used in the track tree
      */
     public function __construct(
         string $trackRoot,
-        string $exerciseSlug,
+        ItemFactory $itemFactory,
     );
 
+    /**
+     * @param string $slug  The slug of this exercise used in the track tree
+     */
+    public function forSlug(string $slug): void;
+
     /** The location of this exercises files in the track tree */
     public function pathToExercise(): string;
 
-    /** The content of the canonical data from the problem specification */
-    public function canonicalData(): CanonicalData;
+    /** The location of this exercises test file in the track tree */
+    public function pathToTestFile(): string;
+
+    /** The content of this exercises test file */
+    public function testFileContent(): string;
+
+    /** The location of this exercises solution file in the track tree */
+    public function pathToSolutionFile(): string;
+
+    /** The content of this exercises solution file */
+    public function solutionFileContent(): string;
 }
diff --git a/contribution/generator/src/TrackData/Group.php b/contribution/generator/src/TrackData/Group.php
new file mode 100644
index 00000000..5f67b623
--- /dev/null
+++ b/contribution/generator/src/TrackData/Group.php
@@ -0,0 +1,109 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\TrackData;
+
+use App\TrackData\Item;
+
+/**
+ * Represents a folding section of thematically connected Items
+ */
+class Group implements Item
+{
+    /**
+     * PHP_EOL is CRLF on Windows, we always want LF
+     * @see https://www.php.net/manual/en/reserved.constants.php#constant.php-eol
+     */
+    private const LF = "\n";
+
+    /**
+     * @param string[] $comments
+     */
+    private function __construct(
+        private InnerGroup $cases,
+        private string $description = '',
+        private array $comments = [],
+        private ?Unknown $unknown = null,
+    ) {
+    }
+
+    public static function from(mixed $rawData): ?Item
+    {
+        if (
+            ! (
+                \is_object($rawData)
+                && isset($rawData->cases)
+            )
+        ) {
+            return null;
+        }
+
+        $cases = InnerGroup::from($rawData->cases);
+        unset($rawData->cases);
+        $description = $rawData->description ?? '';
+        unset($rawData->description);
+        $comments = $rawData->comments ?? [];
+        unset($rawData->comments);
+        $unknown = empty(\get_object_vars($rawData)) ? null : Unknown::from($rawData);
+
+        return new static($cases, $description, $comments, $unknown);
+    }
+
+    public function renderPhpCode(): string
+    {
+        return \sprintf(
+            $this->template(),
+            $this->renderTests(),
+            $this->renderHeadingComment(),
+            $this->renderUnknown(),
+        );
+    }
+
+    /**
+     * %1$s Pre-rendered list of tests
+     * %2$s Multiline comment
+     */
+    private function template(): string
+    {
+        return \file_get_contents(__DIR__ . '/group.txt');
+    }
+
+    private function renderTests(): string
+    {
+        $tests = $this->cases->renderPhpCode();
+
+        return empty($tests) ? '' : $tests . self::LF . self::LF;
+    }
+
+    private function renderHeadingComment(): string
+    {
+        $lines = [];
+        if (!empty($this->description)) {
+            $lines[] = $this->description;
+        }
+        if (!empty($this->description) && !empty($this->comments)) {
+            $lines[] = '';
+        }
+        $lines = [...$lines, ...$this->comments];
+
+        return empty($lines) ? '' : $this->asMultiLineComment($lines);
+    }
+
+    private function renderUnknown(): string
+    {
+        return $this->unknown === null
+            ? ''
+            : $this->unknown->renderPhpCode() . self::LF
+            ;
+    }
+
+    private function asMultiLineComment(array $lines): string
+    {
+        return self::LF
+            . '/*' . self::LF
+            . ' * ' . implode(self::LF . ' * ', $lines) . self::LF
+            . ' */' . self::LF
+            ;
+    }
+}
diff --git a/contribution/generator/src/TrackData/InnerGroup.php b/contribution/generator/src/TrackData/InnerGroup.php
new file mode 100644
index 00000000..66d365a5
--- /dev/null
+++ b/contribution/generator/src/TrackData/InnerGroup.php
@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\TrackData;
+
+use App\TrackData\Item;
+
+/**
+ * Represents a list of Items
+ */
+class InnerGroup implements Item
+{
+    /**
+     * PHP_EOL is CRLF on Windows, we always want LF
+     * @see https://www.php.net/manual/en/reserved.constants.php#constant.php-eol
+     */
+    private const LF = "\n";
+
+    private function __construct(
+        private array $cases,
+    ) {
+    }
+
+    public static function from(mixed $rawData): ?Item
+    {
+        if (!\is_array($rawData))
+            return null;
+
+        $itemFactory = new ItemFactory();
+
+        return new static(\array_map(
+            fn ($rawCase): Item => $itemFactory->from($rawCase),
+            $rawData,
+        ));
+    }
+
+    public function renderPhpCode(): string
+    {
+        return \implode(self::LF, \array_map(
+            fn ($case): string => $case->renderPhpCode(),
+            $this->cases,
+        ));
+    }
+}
diff --git a/contribution/generator/src/TrackData/Item.php b/contribution/generator/src/TrackData/Item.php
new file mode 100644
index 00000000..69638b63
--- /dev/null
+++ b/contribution/generator/src/TrackData/Item.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\TrackData;
+
+interface Item
+{
+    /**
+     * Produce item instance from the given data or null if not possible
+     */
+    public static function from(mixed $rawData): ?Item;
+
+    /**
+     * Render a PHP code representation of the item and its children (if any)
+     */
+    public function renderPhpCode(): string;
+}
diff --git a/contribution/generator/src/TrackData/ItemFactory.php b/contribution/generator/src/TrackData/ItemFactory.php
new file mode 100644
index 00000000..5296b6d7
--- /dev/null
+++ b/contribution/generator/src/TrackData/ItemFactory.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\TrackData;
+
+use App\TrackData\CanonicalData;
+use App\TrackData\InnerGroup;
+use App\TrackData\Item;
+use App\TrackData\Unknown;
+
+/**
+ * Produces Item instances of whatever type is possible
+ */
+class ItemFactory
+{
+    public function from(mixed $rawData): Item
+    {
+        $case = TestCase::from($rawData);
+        if ($case === null)
+            // Despite being rare, CanonicalData must be before Group.
+            // Otherwise Group handles the CanonicalData with many unknown keys.
+            $case = CanonicalData::from($rawData);
+        if ($case === null)
+            $case = Group::from($rawData);
+        if ($case === null)
+            $case = InnerGroup::from($rawData);
+        if ($case === null)
+            $case = Unknown::from($rawData);
+        return $case;
+    }
+}
diff --git a/contribution/generator/src/TrackData/MetaConfigFiles.php b/contribution/generator/src/TrackData/MetaConfigFiles.php
new file mode 100644
index 00000000..320ff2aa
--- /dev/null
+++ b/contribution/generator/src/TrackData/MetaConfigFiles.php
@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\TrackData;
+
+class MetaConfigFiles
+{
+    /**
+     * @param string[] $solutionFiles
+     * @param string[] $testFiles
+     * @param string[] $exampleFiles
+     */
+    public function __construct(
+        public array $solutionFiles = [],
+        public array $testFiles = [],
+        public array $exampleFiles = [],
+    ) {
+    }
+}
diff --git a/contribution/generator/src/TrackData/PracticeExercise.php b/contribution/generator/src/TrackData/PracticeExercise.php
index 3b2472a8..42811a63 100644
--- a/contribution/generator/src/TrackData/PracticeExercise.php
+++ b/contribution/generator/src/TrackData/PracticeExercise.php
@@ -4,26 +4,39 @@
 
 namespace App\TrackData;
 
+use App\Configlet;
 use App\TrackData\CanonicalData;
-use App\TrackData\CanonicalData\TestCase;
 use App\TrackData\Exercise;
 use RuntimeException;
+use Symfony\Contracts\Service\Attribute\Required;
 
 class PracticeExercise implements Exercise
 {
-    private string $pathToConfiglet = '';
-    private string $pathToPracticeExercises = '';
+    private const PATH_TO_EXERCISES = '/exercises/practice/';
+    private const PATH_TO_CONFIG = '/.meta/config.json';
+
+    private ?Configlet $configlet = null;
+    private ?MetaConfigFiles $configFiles = null;
+
+    private string $exerciseSlug = '';
     private string $pathToExercise = '';
-    private string $pathToCanonicalData = '';
 
     public function __construct(
         private string $trackRoot,
-        private string $exerciseSlug,
-    ) {
-        $this->pathToConfiglet = $trackRoot . '/bin/configlet';
-        $this->pathToPracticeExercises = $trackRoot . '/exercises/practice/';
+        private ItemFactory $itemFactory,
+    ) {}
+
+    #[Required]
+    public function setConfiglet(Configlet $configlet): void
+    {
+        $this->configlet = $configlet;
+    }
+
+    public function forSlug(string $slug): void
+    {
+        $this->exerciseSlug = $slug;
         $this->pathToExercise =
-            $this->pathToPracticeExercises . $this->exerciseSlug;
+            $this->trackRoot . self::PATH_TO_EXERCISES . $slug;
     }
 
     public function pathToExercise(): string
@@ -31,65 +44,75 @@ public function pathToExercise(): string
         return $this->pathToExercise;
     }
 
-    public function canonicalData(): CanonicalData
+    public function pathToTestFile(): string
+    {
+        return $this->pathToExercise . '/' . $this->testFileName();
+    }
+
+    public function testFileContent(): string
     {
-        $this->ensureConfigletCanBeUsed();
         $this->ensurePracticeExerciseCanBeUsed();
-        $this->pathToCachedCanonicalDataFromConfiglet();
-        $this->ensurePathToCanonicalDataCanBeUsed();
 
-        return $this->hydratedCanonicalData();
+        // This returns an object derived from stdClass, so adding keys is safe
+        $rawData = $this->loadCanonicalData();
+        $rawData->testClassName = $this->classNameFrom($this->testFileName());
+        $rawData->solutionFileName = $this->solutionFileName();
+        $rawData->solutionClassName = $this->classNameFrom($this->solutionFileName());
+
+        return $this->itemFactory->from($rawData)->renderPhpCode();
     }
 
-    private function hydratedCanonicalData(): CanonicalData
+    public function pathToSolutionFile(): string
     {
-        $canonicalData = \json_decode(
-            json: \file_get_contents($this->pathToCanonicalData),
-            flags: JSON_THROW_ON_ERROR
-        );
+        return $this->pathToExercise . '/' . $this->solutionFileName();
+    }
 
-        // TODO: Validate
-        return new CanonicalData(
-            $canonicalData->exercise,
-            $this->hydrateTestCasesFrom($canonicalData->cases),
-            $canonicalData->comments,
-        );
+    public function solutionFileContent(): string
+    {
+        $this->ensurePracticeExerciseCanBeUsed();
+
+        // TODO: Implement this...
+        return 'To be defined';
     }
 
-    private function hydrateTestCasesFrom(array $rawData): array
+    private function testFileName(): string
     {
-        // TODO: Validate
-        return array_map(fn ($case) => new TestCase(
-            $case->uuid ?? null,
-            $case->description ?? null,
-            $case->property ?? null,
-            $case->input ?? null,
-            $case->expected ?? null,
-            $case->comments ?? [],
-        ), $rawData);
+        return $this->metaConfigFiles()->testFiles[0];
     }
 
-    private function ensureConfigletCanBeUsed(): void
+    private function solutionFileName(): string
     {
-        if (
-            !(
-                is_executable($this->pathToConfiglet)
-                && is_file($this->pathToConfiglet)
-            )
-        ) {
-            throw new RuntimeException(
-                'configlet not found. Run the generator from track root.'
-                . ' Fetch configlet and create exercise with configlet first!'
+        return $this->metaConfigFiles()->solutionFiles[0];
+    }
+
+    private function classNameFrom(string $fileName): string
+    {
+        // This track follows PSR-4 naming convention
+        return \str_replace(".php", "", $fileName);
+    }
+
+    private function metaConfigFiles(): MetaConfigFiles
+    {
+        if (!$this->configFiles instanceof MetaConfigFiles)
+        {
+            $metaConfig = $this->loadMetaConfig();
+
+            $this->configFiles = new MetaConfigFiles(
+                $metaConfig->files->solution,
+                $metaConfig->files->test,
+                $metaConfig->files->example,
             );
         }
+
+        return $this->configFiles;
     }
 
     private function ensurePracticeExerciseCanBeUsed(): void
     {
         if (
             !(
-                is_writable($this->pathToExercise)
-                && is_dir($this->pathToExercise)
+                \is_writable($this->pathToExercise)
+                && \is_dir($this->pathToExercise)
             )
         ) {
             throw new RuntimeException(
@@ -97,60 +120,26 @@ private function ensurePracticeExerciseCanBeUsed(): void
                 . ' configlet first or check access rights!'
             );
         }
+        // TODO: Validate metaConfigFiles(): one test file, one solution file, one example file
     }
 
-    private function pathToCachedCanonicalDataFromConfiglet(): void
+    private function loadCanonicalData(): object
     {
-        /*
-        When running configlet with detailed output (-v d) and a command that
-        requires problem specification data (e.g. info), it prints the location
-        of the cache as the first line. To avoid an HTTP call, use the offline
-        mode (-o).
-
-        Pipe the output through 'head' to get the first line only, then 'cut'
-        the 5th field to get the path only.
-
-        configlet may fail when there is no cached data (offline mode), which
-        tells us, that the exercise hasn't been generated before (the cache is
-        required for that, too). So BASH must use `-eo pipefail` to get the
-        failure code back.
-        */
-        $command = 'bash -c \'set -eo pipefail; '
-            . $this->pathToConfiglet
-            . ' -v d -t '
-            . $this->trackRoot
-            . ' info -o | head -1 | cut -d " " -f 5\''
-            ;
-        $resultCode = 1;
-
-        $configletCache = \exec(command: $command, result_code: $resultCode);
-        if ($configletCache === false || $resultCode !== 0) {
-            throw new RuntimeException(
-                '"configlet" could not provide cached canonical data.'
-                . ' Create exercise with configlet first!'
-            );
-        }
-
-        $this->pathToCanonicalData = \sprintf(
-            '%1$s/exercises/%2$s/canonical-data.json',
-            $configletCache,
-            $this->exerciseSlug
+        return \json_decode(
+            json: \file_get_contents(
+                $this->configlet->pathToCanonicalData($this->exerciseSlug)
+            ),
+            flags: JSON_THROW_ON_ERROR
         );
     }
 
-    private function ensurePathToCanonicalDataCanBeUsed(): void
+    private function loadMetaConfig(): object
     {
-        if (
-            !(
-                is_readable($this->pathToCanonicalData)
-                && is_file($this->pathToCanonicalData)
-            )
-        ) {
-            throw new RuntimeException(
-                'Cannot read "configlet" provided cached canonical data from '
-                . $this->pathToCanonicalData
-                . '. Check exercise slug or access rights!'
-            );
-        }
+        return \json_decode(
+            json: \file_get_contents(
+                $this->pathToExercise . self::PATH_TO_CONFIG
+            ),
+            flags: JSON_THROW_ON_ERROR
+        );
     }
 }
diff --git a/contribution/generator/src/TrackData/TestCase.php b/contribution/generator/src/TrackData/TestCase.php
new file mode 100644
index 00000000..ed59c9c9
--- /dev/null
+++ b/contribution/generator/src/TrackData/TestCase.php
@@ -0,0 +1,156 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\TrackData;
+
+use App\TrackData\Item;
+
+/**
+ * Represents an Item, that is testing something with property(input) === expected
+ */
+class TestCase implements Item
+{
+    /**
+     * PHP_EOL is CRLF on Windows, we always want LF
+     * @see https://www.php.net/manual/en/reserved.constants.php#constant.php-eol
+     */
+    private const LF = "\n";
+
+    public function __construct(
+        private string $uuid,
+        private string $description,
+        private string $property,
+        private object $input,
+        private mixed $expected,
+        private array $comments = [],
+        private ?object $unknown = null,
+    ) {
+    }
+
+    public static function from(mixed $rawData): ?Item
+    {
+        if (!\is_object($rawData))
+            return null;
+
+        $requiredProperties = [
+            'uuid',
+            'description',
+            'property',
+            'input',
+            'expected',
+        ];
+        $actualProperties = \array_keys(\get_object_vars($rawData));
+        $data = [];
+        foreach ($requiredProperties as $requiredProperty) {
+            if (!\in_array($requiredProperty, $actualProperties)) {
+                return null;
+            }
+            $data[$requiredProperty] = $rawData->{$requiredProperty};
+            unset($rawData->{$requiredProperty});
+        }
+
+        return new static(
+            ...$data,
+            unknown: empty(\get_object_vars($rawData)) ? null : $rawData,
+        );
+    }
+
+    public function renderPhpCode(): string
+    {
+        return \sprintf(
+            $this->template(),
+            $this->testMethodName(),
+            $this->renderUnknownData(),
+            $this->indentTrailingLines(\var_export((array)$this->input, true)),
+            $this->invocationReturnsValue()
+                ? $this->renderAssignExpected()
+                : $this->renderAssertionOnException()
+                ,
+            $this->uuid,
+            \ucfirst($this->description),
+            $this->property,
+            $this->invocationReturnsValue()
+                ? $this->renderAssertionOnExpected()
+                : ''
+                ,
+        );
+    }
+
+    private function invocationReturnsValue(): bool
+    {
+        return !(
+            \is_object($this->expected)
+            && isset($this->expected->error)
+            && gettype($this->expected->error) === 'string'
+        );
+    }
+
+    /**
+     * %1$s Method name
+     * %2$s Unknow data
+     * %3$s Input data
+     * %4$s Expected data or exception
+     * %5$s UUID
+     * %6$s Testdox
+     * %7$s Property (method to call)
+     * %8$s Assertion on expected
+     */
+    private function template(): string
+    {
+        return \file_get_contents(__DIR__ . '/test-case.txt');
+    }
+
+    private function testMethodName(): string
+    {
+        $sanitizedDescription = \preg_replace('/\W+/', ' ', $this->description);
+
+        $methodNameParts = \explode(' ', $sanitizedDescription);
+        $upperCasedParts = \array_map(
+            fn ($part) => \ucfirst($part),
+            $methodNameParts
+        );
+
+        return \lcfirst(\implode('', $upperCasedParts));
+    }
+
+    private function renderUnknownData(): string
+    {
+        if ($this->unknown === null)
+            return '';
+        return 'Unknown data:' . self::LF
+            . ' * ' . \json_encode($this->unknown) . self::LF
+            . ' * ' . self::LF
+            . ' * '
+            ;
+    }
+
+    private function renderAssignExpected(): string
+    {
+        return $this->indentTrailingLines('$expected = ' . \var_export($this->expected, true));
+    }
+
+    private function renderAssertionOnExpected(): string
+    {
+        return
+            '    ' . self::LF
+            . '    $this->assertSame($expected, $actual);' . self::LF
+            ;
+    }
+
+    private function renderAssertionOnException(): string
+    {
+        return $this->indentTrailingLines(
+            '$this->expectException(\Exception::class);' . self::LF
+            . '$this->expectExceptionMessage(\''
+            . $this->expected->error
+            . '\')'
+        );
+    }
+
+    private function indentTrailingLines(string $lines): string
+    {
+        $indent = '    ';
+        return \implode(self::LF . $indent, \explode(self::LF, $lines));
+    }
+}
diff --git a/contribution/generator/src/TrackData/Unknown.php b/contribution/generator/src/TrackData/Unknown.php
new file mode 100644
index 00000000..61858516
--- /dev/null
+++ b/contribution/generator/src/TrackData/Unknown.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\TrackData;
+
+use App\TrackData\Item;
+
+/**
+ * Represents an Item, that is not one of the known other types
+ */
+class Unknown implements Item
+{
+    private function __construct(
+        private mixed $data = null,
+    ) {
+    }
+
+    public static function from(mixed $rawData): ?Item
+    {
+        return new static($rawData);
+    }
+
+    public function renderPhpCode(): string
+    {
+        return \sprintf(
+            $this->template(),
+            \json_encode($this->data),
+        );
+    }
+
+    private function template(): string
+    {
+        return \file_get_contents(__DIR__ . '/unknown.txt');
+    }
+}
diff --git a/contribution/generator/src/TrackData/canonical-data.txt b/contribution/generator/src/TrackData/canonical-data.txt
new file mode 100644
index 00000000..89b37fc4
--- /dev/null
+++ b/contribution/generator/src/TrackData/canonical-data.txt
@@ -0,0 +1,33 @@
+<?php
+%1$s
+declare (strict_types=1);
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * %3$s- Please use `assertSame()` whenever possible. Add a comment with reason
+ *   when it is not possible.
+ * - Do not use calls with named arguments. Use them only when the
+ *   exercise requires named arguments (e.g. because the exercise is
+ *   about named arguments).
+ *   Named arguments are in the way of defining argument names the
+ *   students want (e.g. in their native language).
+ * - Add `@testdox` with a useful test title, e.g. the test case heading
+ *   from canonical data. The online editor shows that to students.
+ * - Add fail messages to assertions where helpful to tell students more
+ *   than `@testdox` says.
+ */
+final class %4$s extends TestCase
+{
+    private %6$s $subject;
+    
+    public static function setUpBeforeClass(): void
+    {
+        require_once('%5$s');
+    }
+    
+    public function setUp(): void
+    {
+        $this->subject = new %6$s();
+    }
+%2$s}
diff --git a/contribution/generator/src/TrackData/group.txt b/contribution/generator/src/TrackData/group.txt
new file mode 100644
index 00000000..df56f726
--- /dev/null
+++ b/contribution/generator/src/TrackData/group.txt
@@ -0,0 +1,2 @@
+%2$s// {{{
+%3$s%1$s// }}}
\ No newline at end of file
diff --git a/contribution/generator/src/TrackData/test-case.txt b/contribution/generator/src/TrackData/test-case.txt
new file mode 100644
index 00000000..fba305da
--- /dev/null
+++ b/contribution/generator/src/TrackData/test-case.txt
@@ -0,0 +1,15 @@
+
+/**
+ * %2$suuid: %5$s
+ * @testdox %6$s
+ * @test
+ */
+public function %1$s(): void
+{
+    $this->markTestSkipped('This test has not been verified yet.');
+    
+    $input = %3$s;
+    %4$s;
+    
+    $actual = $this->subject->%7$s(...$input);
+%8$s}
\ No newline at end of file
diff --git a/contribution/generator/src/TrackData/unknown.txt b/contribution/generator/src/TrackData/unknown.txt
new file mode 100644
index 00000000..6bb0af06
--- /dev/null
+++ b/contribution/generator/src/TrackData/unknown.txt
@@ -0,0 +1,5 @@
+
+/*
+ * Unknown data:
+ * %1$s
+ */
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/AssertStringOrder.php b/contribution/generator/tests/TestGeneration/AssertStringOrder.php
new file mode 100644
index 00000000..ed600248
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/AssertStringOrder.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Tests\TestGeneration;
+
+trait AssertStringOrder
+{
+    private function assertStringContainsStringBeforeString(
+        string $firstString,
+        string $secondString,
+        string $actualString,
+        string $message = '',
+    ):void {
+        $this->assertStringContainsString($firstString, $actualString);
+        $this->assertStringContainsString($secondString, $actualString);
+
+        $firstPosition = \strpos($actualString, $firstString);
+        $secondPosition = \strpos($actualString, $secondString);
+
+        if ($firstPosition > $secondPosition) {
+            $this->fail(
+                empty($message)
+                ? 'Failed asserting that "'
+                    . $actualString
+                    . '" contains "'
+                    . $firstString
+                    . '" before "'
+                    . $secondString
+                    . '"'
+                : $message
+            );
+        }
+        // Count the above conditional as assertion
+        // Using this assertion directly results in misleading failure message
+        $this->assertTrue(true);
+    }
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/CanonicalDataTest.php b/contribution/generator/tests/TestGeneration/CanonicalData/CanonicalDataTest.php
new file mode 100644
index 00000000..43b10938
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/CanonicalDataTest.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace App\Tests\TestGeneration\CanonicalData;
+
+use App\Tests\TestGeneration\ScenarioFixture;
+use App\TrackData\CanonicalData;
+use App\TrackData\Item;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\Attributes\TestDox;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * The problem specification has no `testClassName`, `solutionFileName`,
+ * `solutionClassName` fields, these are added by the exercise to unify the
+ * interface to `Item`. This way, `ItemFactory` can produce a `CanonicalData`
+ * item, too.
+ */
+#[TestDox('Canonical Data (App\Tests\TestGeneration\CanonicalData\CanonicalDataTest)')]
+final class CanonicalDataTest extends TestCase
+{
+    use ScenarioFixture;
+
+    #[Test]
+    public function implementsItemInterface(): void
+    {
+        $subject = $this->subjectFor('non-varying-parts');
+
+        $this->assertInstanceOf(Item::class, $subject);
+    }
+
+    #[Test]
+    #[TestDox('$_dataName')]
+    #[DataProvider('nonRenderingScenarios')]
+    public function testNonRenderingScenario(
+        string $scenario,
+    ): void {
+        $subject = $this->subjectFor($scenario);
+
+        $this->assertNull($subject);
+    }
+
+    public static function nonRenderingScenarios(): array
+    {
+        return [
+            'When given no object, then returns null'
+                => [ 'no-object' ],
+            'When given object without "testClassName", then returns null'
+                => [ 'no-test-class-name' ],
+            'When given object without "solutionFileName", then returns null'
+                => [ 'no-solution-file-name' ],
+            'When given object without "solutionClassName", then returns null'
+                => [ 'no-solution-class-name' ],
+        ];
+    }
+
+    #[Test]
+    #[TestDox('$_dataName')]
+    #[DataProvider('renderingScenarios')]
+    public function testRenderingScenario(
+        string $scenario,
+    ): void {
+        $subject = $this->subjectFor($scenario);
+
+        $actual = $subject->renderPhpCode();
+
+        $this->assertStringContainsAllOfScenario($scenario, $actual);
+    }
+
+    public static function renderingScenarios(): array
+    {
+        return [
+            // This scenario asserts on the constant parts and their position in relation to the varying part(s)
+            'When given a valid object with all keys, then renders all non-varying parts where they belong'
+                => [ 'non-varying-parts' ],
+
+            // These scenarios assert on the varying part(s)
+
+            'When given a test class name, then renders that test class name into stub'
+                => [ 'test-class-name' ],
+            'When given a solution file name, then renders that file name into stub'
+                => [ 'solution-file-name'],
+            'When given a solution class name, then renders that class name into stub'
+                => [ 'solution-class-name' ],
+
+            // "exercise" is ignored
+            'When given object with no "exercise", then renders only test class stub'
+                => [ 'no-exercise' ],
+            'When given object with "exercise", then renders only test class stub'
+                => [ 'exercise' ],
+
+            'When given object with no unknown key, then renders no multi-line comment'
+                => [ 'no-unknown-key' ],
+            'When given object with an unknown key, then renders the key as JSON in multi-line comment'
+                => [ 'one-unknown-key' ],
+            'When given object with many unknown keys, then renders all keys as JSON in multi-line comment'
+                => [ 'many-unknown-keys' ],
+
+            'When given a valid object with no "comments", then renders no comments part'
+                => [ 'no-comments' ],
+            'When given object with singleline "comments", then renders comment in class DocBlock'
+                => [ 'one-line-comments' ],
+            'When given object with multiline "comments", then renders test class with comments in class DocBlock'
+                => [ 'many-line-comments' ],
+
+            // Here we need to check for rendering / not rendering only
+            'When given a valid object with no "cases", then renders no cases'
+                => [ 'no-cases' ],
+            'When given a valid object with "cases", then renders cases'
+                => [ 'cases' ],
+        ];
+    }
+
+    private function subjectFor(string $scenario): ?CanonicalData
+    {
+        return CanonicalData::from($this->rawDataFor($scenario));
+    }
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/cases/01-start-to-list-item.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/cases/01-start-to-list-item.txt
new file mode 100644
index 00000000..9c73870d
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/cases/01-start-to-list-item.txt
@@ -0,0 +1,3 @@
+    }
+    
+    /*
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/cases/02-list-item-to-end.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/cases/02-list-item-to-end.txt
new file mode 100644
index 00000000..aed86433
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/cases/02-list-item-to-end.txt
@@ -0,0 +1,2 @@
+     */
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/cases/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/cases/input.json
new file mode 100644
index 00000000..d78975e0
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/cases/input.json
@@ -0,0 +1,8 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "SomeSolutionClass",
+    "cases": [
+        { "an-unknown-item": "will render as multiline comment with JSON" }
+    ]
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/cases/list-item.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/cases/list-item.txt
new file mode 100644
index 00000000..83150949
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/cases/list-item.txt
@@ -0,0 +1 @@
+     * Unknown data:
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/exercise/expected.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/exercise/expected.txt
new file mode 100644
index 00000000..f4096f4c
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/exercise/expected.txt
@@ -0,0 +1,33 @@
+<?php
+
+declare (strict_types=1);
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * - Please use `assertSame()` whenever possible. Add a comment with reason
+ *   when it is not possible.
+ * - Do not use calls with named arguments. Use them only when the
+ *   exercise requires named arguments (e.g. because the exercise is
+ *   about named arguments).
+ *   Named arguments are in the way of defining argument names the
+ *   students want (e.g. in their native language).
+ * - Add `@testdox` with a useful test title, e.g. the test case heading
+ *   from canonical data. The online editor shows that to students.
+ * - Add fail messages to assertions where helpful to tell students more
+ *   than `@testdox` says.
+ */
+final class SomeTestClass extends TestCase
+{
+    private SomeSolutionClass $subject;
+    
+    public static function setUpBeforeClass(): void
+    {
+        require_once('SomeSolutionFile.ext');
+    }
+    
+    public function setUp(): void
+    {
+        $this->subject = new SomeSolutionClass();
+    }
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/exercise/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/exercise/input.json
new file mode 100644
index 00000000..65a8eee6
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/exercise/input.json
@@ -0,0 +1,6 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "SomeSolutionClass",
+    "exercise": "This is ignored"
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-line-comments/expected.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-line-comments/expected.txt
new file mode 100644
index 00000000..0535b1b6
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-line-comments/expected.txt
@@ -0,0 +1,4 @@
+ * Some lines of comments
+ * 
+ * Just to have something in here.
+ * 
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-line-comments/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-line-comments/input.json
new file mode 100644
index 00000000..79ae8ab9
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-line-comments/input.json
@@ -0,0 +1,10 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "SomeSolutionClass",
+    "comments": [
+        "Some lines of comments",
+        "",
+        "Just to have something in here."
+    ]
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-unknown-cases/expected.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-unknown-cases/expected.txt
new file mode 100644
index 00000000..bf7329b6
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-unknown-cases/expected.txt
@@ -0,0 +1,17 @@
+    }
+    
+    /*
+     * Unknown data:
+     * {"an-unknown-item":"will render as multiline comment with JSON"}
+     */
+    
+    /*
+     * Unknown data:
+     * {"another-unknown-item":"will render as multiline comment with JSON"}
+     */
+    
+    /*
+     * Unknown data:
+     * {"a-last-unknown-item":"will render as multiline comment with JSON"}
+     */
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-unknown-cases/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-unknown-cases/input.json
new file mode 100644
index 00000000..3ec9fe90
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-unknown-cases/input.json
@@ -0,0 +1,10 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "SomeSolutionClass",
+    "cases": [
+        { "an-unknown-item": "will render as multiline comment with JSON" },
+        { "another-unknown-item": "will render as multiline comment with JSON" },
+        { "a-last-unknown-item": "will render as multiline comment with JSON" }
+    ]
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-unknown-keys/expected.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-unknown-keys/expected.txt
new file mode 100644
index 00000000..4fdeb21e
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-unknown-keys/expected.txt
@@ -0,0 +1,5 @@
+
+/* Unknown data:
+ * {"some-unknown-key":"with a value","another-unknown-key":"and another value"}
+ */
+
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-unknown-keys/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-unknown-keys/input.json
new file mode 100644
index 00000000..36711452
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/many-unknown-keys/input.json
@@ -0,0 +1,7 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "SomeSolutionClass",
+    "some-unknown-key": "with a value",
+    "another-unknown-key": "and another value"
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-cases/01-solution-class-name-to-end.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-cases/01-solution-class-name-to-end.txt
new file mode 100644
index 00000000..f9122f4b
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-cases/01-solution-class-name-to-end.txt
@@ -0,0 +1,3 @@
+        $this->subject = new SomeSolutionClass();
+    }
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-cases/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-cases/input.json
new file mode 100644
index 00000000..c9290a49
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-cases/input.json
@@ -0,0 +1,9 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "SomeSolutionClass",
+    "exercise": "some-exercise-slug",
+    "comments": [
+        "One line in comments"
+    ]
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-comments/01-start-to-test-class-name.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-comments/01-start-to-test-class-name.txt
new file mode 100644
index 00000000..f16ee949
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-comments/01-start-to-test-class-name.txt
@@ -0,0 +1,20 @@
+<?php
+
+declare (strict_types=1);
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * - Please use `assertSame()` whenever possible. Add a comment with reason
+ *   when it is not possible.
+ * - Do not use calls with named arguments. Use them only when the
+ *   exercise requires named arguments (e.g. because the exercise is
+ *   about named arguments).
+ *   Named arguments are in the way of defining argument names the
+ *   students want (e.g. in their native language).
+ * - Add `@testdox` with a useful test title, e.g. the test case heading
+ *   from canonical data. The online editor shows that to students.
+ * - Add fail messages to assertions where helpful to tell students more
+ *   than `@testdox` says.
+ */
+final class SomeTestClass extends TestCase
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-comments/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-comments/input.json
new file mode 100644
index 00000000..3eb9d9ea
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-comments/input.json
@@ -0,0 +1,9 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "SomeSolutionClass",
+    "exercise": "some-exercise-slug",
+    "cases": [
+        { "some-unknown-case-item": "some value" }
+    ]
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-exercise/expected.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-exercise/expected.txt
new file mode 100644
index 00000000..f4096f4c
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-exercise/expected.txt
@@ -0,0 +1,33 @@
+<?php
+
+declare (strict_types=1);
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * - Please use `assertSame()` whenever possible. Add a comment with reason
+ *   when it is not possible.
+ * - Do not use calls with named arguments. Use them only when the
+ *   exercise requires named arguments (e.g. because the exercise is
+ *   about named arguments).
+ *   Named arguments are in the way of defining argument names the
+ *   students want (e.g. in their native language).
+ * - Add `@testdox` with a useful test title, e.g. the test case heading
+ *   from canonical data. The online editor shows that to students.
+ * - Add fail messages to assertions where helpful to tell students more
+ *   than `@testdox` says.
+ */
+final class SomeTestClass extends TestCase
+{
+    private SomeSolutionClass $subject;
+    
+    public static function setUpBeforeClass(): void
+    {
+        require_once('SomeSolutionFile.ext');
+    }
+    
+    public function setUp(): void
+    {
+        $this->subject = new SomeSolutionClass();
+    }
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-exercise/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-exercise/input.json
new file mode 100644
index 00000000..30a4e541
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-exercise/input.json
@@ -0,0 +1,5 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "SomeSolutionClass"
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-object/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-object/input.json
new file mode 100644
index 00000000..fe51488c
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-object/input.json
@@ -0,0 +1 @@
+[]
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-solution-class-name/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-solution-class-name/input.json
new file mode 100644
index 00000000..5439d7b7
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-solution-class-name/input.json
@@ -0,0 +1,4 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "SomeSolutionFile.ext"
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-solution-file-name/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-solution-file-name/input.json
new file mode 100644
index 00000000..dedfe1b6
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-solution-file-name/input.json
@@ -0,0 +1,4 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionClassName": "SomeSolutionClass"
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-test-class-name/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-test-class-name/input.json
new file mode 100644
index 00000000..6c93dca7
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-test-class-name/input.json
@@ -0,0 +1,4 @@
+{
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "SomeSolutionClass"
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-unknown-key/01-start-to-declare.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-unknown-key/01-start-to-declare.txt
new file mode 100644
index 00000000..f12df890
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-unknown-key/01-start-to-declare.txt
@@ -0,0 +1,3 @@
+<?php
+
+declare (strict_types=1);
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-unknown-key/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-unknown-key/input.json
new file mode 100644
index 00000000..d7f74a00
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/no-unknown-key/input.json
@@ -0,0 +1,12 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "SomeSolutionClass",
+    "exercise": "some-exercise-slug",
+    "comments": [
+        "One line in comments"
+    ],
+    "cases": [
+        { "some-unknown-case-item": "some value" }
+    ]
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/01-start-to-unknown.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/01-start-to-unknown.txt
new file mode 100644
index 00000000..bcc7e5c8
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/01-start-to-unknown.txt
@@ -0,0 +1,3 @@
+<?php
+
+/* Unknown data:
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/02-unknown-to-comments.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/02-unknown-to-comments.txt
new file mode 100644
index 00000000..a6713356
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/02-unknown-to-comments.txt
@@ -0,0 +1,8 @@
+ */
+
+declare (strict_types=1);
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * One line in comments
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/03-comments-to-test-class-name.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/03-comments-to-test-class-name.txt
new file mode 100644
index 00000000..b4159ba6
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/03-comments-to-test-class-name.txt
@@ -0,0 +1,15 @@
+ * One line in comments
+ * 
+ * - Please use `assertSame()` whenever possible. Add a comment with reason
+ *   when it is not possible.
+ * - Do not use calls with named arguments. Use them only when the
+ *   exercise requires named arguments (e.g. because the exercise is
+ *   about named arguments).
+ *   Named arguments are in the way of defining argument names the
+ *   students want (e.g. in their native language).
+ * - Add `@testdox` with a useful test title, e.g. the test case heading
+ *   from canonical data. The online editor shows that to students.
+ * - Add fail messages to assertions where helpful to tell students more
+ *   than `@testdox` says.
+ */
+final class SomeTestClass extends TestCase
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/04-test-class-name-to-solution-class-name.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/04-test-class-name-to-solution-class-name.txt
new file mode 100644
index 00000000..753a9cc8
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/04-test-class-name-to-solution-class-name.txt
@@ -0,0 +1,3 @@
+final class SomeTestClass extends TestCase
+{
+    private SomeSolutionClass $subject;
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/05-solution-class-name-to-solution-file-name.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/05-solution-class-name-to-solution-file-name.txt
new file mode 100644
index 00000000..60575448
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/05-solution-class-name-to-solution-file-name.txt
@@ -0,0 +1,5 @@
+    private SomeSolutionClass $subject;
+    
+    public static function setUpBeforeClass(): void
+    {
+        require_once('SomeSolutionFile.ext');
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/06-solution-file-name-to-solution-class-name.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/06-solution-file-name-to-solution-class-name.txt
new file mode 100644
index 00000000..abe992a4
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/06-solution-file-name-to-solution-class-name.txt
@@ -0,0 +1,6 @@
+        require_once('SomeSolutionFile.ext');
+    }
+    
+    public function setUp(): void
+    {
+        $this->subject = new SomeSolutionClass();
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/07-solution-class-name-to-cases.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/07-solution-class-name-to-cases.txt
new file mode 100644
index 00000000..53f1de06
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/07-solution-class-name-to-cases.txt
@@ -0,0 +1,4 @@
+        $this->subject = new SomeSolutionClass();
+    }
+    
+    /*
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/08-cases-indented-but-not-closing-folding-marks.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/08-cases-indented-but-not-closing-folding-marks.txt
new file mode 100644
index 00000000..6352561c
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/08-cases-indented-but-not-closing-folding-marks.txt
@@ -0,0 +1,2 @@
+
+// }}}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/08-cases-indented-but-not-openning-folding-marks.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/08-cases-indented-but-not-openning-folding-marks.txt
new file mode 100644
index 00000000..89563d68
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/08-cases-indented-but-not-openning-folding-marks.txt
@@ -0,0 +1,2 @@
+
+// {{{
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/09-cases-to-end.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/09-cases-to-end.txt
new file mode 100644
index 00000000..b4016446
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/09-cases-to-end.txt
@@ -0,0 +1,2 @@
+// }}}
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/input.json
new file mode 100644
index 00000000..d4fe68d2
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/non-varying-parts/input.json
@@ -0,0 +1,16 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "SomeSolutionClass",
+    "exercise": "some-exercise-slug",
+    "comments": [
+        "One line in comments"
+    ],
+    "cases": [
+        {
+            "cases": [],
+            "description": "subgroup has unindented folding marks"
+        }
+    ],
+    "some-unknown-key": "some value"
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/one-line-comments/expected.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/one-line-comments/expected.txt
new file mode 100644
index 00000000..9c593523
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/one-line-comments/expected.txt
@@ -0,0 +1,2 @@
+ * A line in comments
+ * 
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/one-line-comments/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/one-line-comments/input.json
new file mode 100644
index 00000000..32f40a50
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/one-line-comments/input.json
@@ -0,0 +1,8 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "SomeSolutionClass",
+    "comments": [
+        "A line in comments"
+    ]
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/one-unknown-key/expected.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/one-unknown-key/expected.txt
new file mode 100644
index 00000000..94e19b5e
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/one-unknown-key/expected.txt
@@ -0,0 +1,5 @@
+
+/* Unknown data:
+ * {"unknown-key":"does not matter"}
+ */
+
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/one-unknown-key/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/one-unknown-key/input.json
new file mode 100644
index 00000000..88120c56
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/one-unknown-key/input.json
@@ -0,0 +1,6 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "SomeSolutionClass",
+    "unknown-key": "does not matter"
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-class-name/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-class-name/input.json
new file mode 100644
index 00000000..26e6acd3
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-class-name/input.json
@@ -0,0 +1,5 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "DifferentSolutionClassName"
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-class-name/property-type-declaration.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-class-name/property-type-declaration.txt
new file mode 100644
index 00000000..6786a18c
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-class-name/property-type-declaration.txt
@@ -0,0 +1 @@
+    private DifferentSolutionClassName $subject;
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-class-name/subject-instantiation.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-class-name/subject-instantiation.txt
new file mode 100644
index 00000000..2943f7a0
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-class-name/subject-instantiation.txt
@@ -0,0 +1 @@
+        $this->subject = new DifferentSolutionClassName();
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-file-name/expected.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-file-name/expected.txt
new file mode 100644
index 00000000..2fe9c023
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-file-name/expected.txt
@@ -0,0 +1 @@
+        require_once('DifferentSolutionFile.php');
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-file-name/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-file-name/input.json
new file mode 100644
index 00000000..ec958e08
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/solution-file-name/input.json
@@ -0,0 +1,5 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "DifferentSolutionFile.php",
+    "solutionClassName": "SomeSolutionClass"
+}
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/test-class-name/expected.txt b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/test-class-name/expected.txt
new file mode 100644
index 00000000..f79613be
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/test-class-name/expected.txt
@@ -0,0 +1 @@
+final class DifferentTestClassName extends TestCase
diff --git a/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/test-class-name/input.json b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/test-class-name/input.json
new file mode 100644
index 00000000..1579e324
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/CanonicalData/fixtures/test-class-name/input.json
@@ -0,0 +1,5 @@
+{
+    "testClassName": "DifferentTestClassName",
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "SomeSolutionClass"
+}
diff --git a/contribution/generator/tests/TestGeneration/Group/GroupTest.php b/contribution/generator/tests/TestGeneration/Group/GroupTest.php
new file mode 100644
index 00000000..524ec053
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/GroupTest.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace App\Tests\TestGeneration\Group;
+
+use App\Tests\TestGeneration\AssertStringOrder;
+use App\Tests\TestGeneration\ScenarioFixture;
+use App\TrackData\Group;
+use App\TrackData\Item;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\Attributes\TestDox;
+use PHPUnit\Framework\TestCase as PHPUnitTestCase;
+
+#[TestDox('Group (App\Tests\TestGeneration\Group\GroupTest)')]
+final class GroupTest extends PHPUnitTestCase
+{
+    use AssertStringOrder;
+    use ScenarioFixture;
+
+    #[Test]
+    public function implementsItemInterface(): void
+    {
+        $subject = $this->subjectFor('empty-cases');
+
+        $this->assertInstanceOf(Item::class, $subject);
+    }
+
+    #[Test]
+    #[TestDox('When given $_dataName, then returns null')]
+    #[DataProvider('nonRenderingScenarios')]
+    public function testNonRenderingScenario(
+        mixed $rawData,
+    ): void {
+        $subject = Group::from($rawData);
+
+        $this->assertNull($subject);
+    }
+
+    public static function nonRenderingScenarios(): array
+    {
+        // All possible types in JSON, but not object
+        // Any object without "cases"
+        return [
+            'an array' => [ [] ],
+            'a bool' => [ true ],
+            'a string' => [ 'some string' ],
+            'an int' => [ 0 ],
+            'a float' => [ 0.0 ],
+            'null' => [ null ],
+            'an empty object' => [ (object)[] ],
+            'an object without "cases"' => [ (object)['some-property' => 'is not cases'] ],
+        ];
+    }
+
+    #[Test]
+    #[TestDox('$_dataName')]
+    #[DataProvider('renderingScenarios')]
+    public function testRenderingScenario(
+        string $scenario,
+    ): void {
+        $subject = $this->subjectFor($scenario);
+
+        $actual = $subject->renderPhpCode();
+
+        $this->assertStringContainsAllOfScenario($scenario, $actual);
+    }
+
+    public static function renderingScenarios(): array
+    {
+        return [
+            'When given an object with empty "cases" list, then renders an empty folding section'
+                => [ 'empty-cases' ],
+            // As we use InnerGroup to render the list, one test case is enough
+            'When given an object with "cases" containing a testcase, then renders the cases list into folding section'
+                => [ 'one-case-in-cases' ],
+
+            'When given "cases" and "description", then renders multiline comment with description above folding section'
+                => [ 'description' ],
+            'When given "cases" and "comments", then renders multiline comment with comments above folding section'
+                => [ 'comments' ],
+            'When given "cases", "description" and "comments", then renders multiline comment with description and comments above folding section'
+                => [ 'description-and-comments' ],
+
+            'When given "cases" and unknown keys, then renders multiline comment with JSON above tests into folding section'
+                => [ 'unknown' ],
+        ];
+    }
+
+    #[Test]
+    #[TestDox('When given "cases", "description" and "comments", then renders description before comments')]
+    public function renderingOrderOfDescriptionAndComments(): void
+    {
+        $subject = $this->subjectFor('description-and-comments');
+
+        $actual = $subject->renderPhpCode();
+
+        $this->assertStringContainsStringBeforeString(
+            'some title for this group of tests',
+            'Some comments',
+            $actual,
+        );
+    }
+
+    private function subjectFor(string $scenario): ?Group
+    {
+        return Group::from($this->rawDataFor($scenario));
+    }
+}
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/comments/01-start-to-comments.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/comments/01-start-to-comments.txt
new file mode 100644
index 00000000..33662f55
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/comments/01-start-to-comments.txt
@@ -0,0 +1 @@
+/*
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/comments/02-comments-to-tests.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/comments/02-comments-to-tests.txt
new file mode 100644
index 00000000..c356ce6e
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/comments/02-comments-to-tests.txt
@@ -0,0 +1,4 @@
+ */
+// {{{
+
+/**
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/comments/03-tests-to-end.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/comments/03-tests-to-end.txt
new file mode 100644
index 00000000..e9b1155b
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/comments/03-tests-to-end.txt
@@ -0,0 +1,3 @@
+}
+
+// }}}
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/comments/comments.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/comments/comments.txt
new file mode 100644
index 00000000..712e1de0
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/comments/comments.txt
@@ -0,0 +1,2 @@
+ * Some comments
+ * on multiple lines
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/comments/input.json b/contribution/generator/tests/TestGeneration/Group/fixtures/comments/input.json
new file mode 100644
index 00000000..91fb5127
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/comments/input.json
@@ -0,0 +1,17 @@
+{
+    "comments":[
+        "Some comments",
+        "on multiple lines"
+    ],
+    "cases": [
+        {
+            "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+            "description": "some description",
+            "property": "camelCasedProperty",
+            "input": {
+                "camelCasedArgumentName": "maybe any type"
+            },
+            "expected": "maybe any type"
+        }
+    ]
+}
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/comments/tests.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/comments/tests.txt
new file mode 100644
index 00000000..13b552a5
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/comments/tests.txt
@@ -0,0 +1 @@
+ * uuid: 31a673f2-5e54-49fe-bd79-1c1dae476c9c
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/01-start-to-description.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/01-start-to-description.txt
new file mode 100644
index 00000000..33662f55
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/01-start-to-description.txt
@@ -0,0 +1 @@
+/*
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/02-description-to-comments.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/02-description-to-comments.txt
new file mode 100644
index 00000000..b077d635
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/02-description-to-comments.txt
@@ -0,0 +1 @@
+ * 
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/03-comments-to-tests.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/03-comments-to-tests.txt
new file mode 100644
index 00000000..c356ce6e
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/03-comments-to-tests.txt
@@ -0,0 +1,4 @@
+ */
+// {{{
+
+/**
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/04-tests-to-end.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/04-tests-to-end.txt
new file mode 100644
index 00000000..e9b1155b
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/04-tests-to-end.txt
@@ -0,0 +1,3 @@
+}
+
+// }}}
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/comments.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/comments.txt
new file mode 100644
index 00000000..712e1de0
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/comments.txt
@@ -0,0 +1,2 @@
+ * Some comments
+ * on multiple lines
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/description.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/description.txt
new file mode 100644
index 00000000..2084025a
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/description.txt
@@ -0,0 +1 @@
+ * some title for this group of tests
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/input.json b/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/input.json
new file mode 100644
index 00000000..7b66a90c
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/input.json
@@ -0,0 +1,18 @@
+{
+    "description": "some title for this group of tests",
+    "comments":[
+        "Some comments",
+        "on multiple lines"
+    ],
+    "cases": [
+        {
+            "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+            "description": "some description",
+            "property": "camelCasedProperty",
+            "input": {
+                "camelCasedArgumentName": "maybe any type"
+            },
+            "expected": "maybe any type"
+        }
+    ]
+}
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/tests.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/tests.txt
new file mode 100644
index 00000000..13b552a5
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/description-and-comments/tests.txt
@@ -0,0 +1 @@
+ * uuid: 31a673f2-5e54-49fe-bd79-1c1dae476c9c
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/description/01-start-to-description.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/description/01-start-to-description.txt
new file mode 100644
index 00000000..33662f55
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/description/01-start-to-description.txt
@@ -0,0 +1 @@
+/*
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/description/02-description-to-tests.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/description/02-description-to-tests.txt
new file mode 100644
index 00000000..c356ce6e
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/description/02-description-to-tests.txt
@@ -0,0 +1,4 @@
+ */
+// {{{
+
+/**
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/description/03-tests-to-end.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/description/03-tests-to-end.txt
new file mode 100644
index 00000000..e9b1155b
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/description/03-tests-to-end.txt
@@ -0,0 +1,3 @@
+}
+
+// }}}
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/description/description.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/description/description.txt
new file mode 100644
index 00000000..2084025a
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/description/description.txt
@@ -0,0 +1 @@
+ * some title for this group of tests
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/description/input.json b/contribution/generator/tests/TestGeneration/Group/fixtures/description/input.json
new file mode 100644
index 00000000..be341244
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/description/input.json
@@ -0,0 +1,14 @@
+{
+    "description": "some title for this group of tests",
+    "cases": [
+        {
+            "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+            "description": "some description",
+            "property": "camelCasedProperty",
+            "input": {
+                "camelCasedArgumentName": "maybe any type"
+            },
+            "expected": "maybe any type"
+        }
+    ]
+}
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/description/tests.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/description/tests.txt
new file mode 100644
index 00000000..13b552a5
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/description/tests.txt
@@ -0,0 +1 @@
+ * uuid: 31a673f2-5e54-49fe-bd79-1c1dae476c9c
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/empty-cases/expected.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/empty-cases/expected.txt
new file mode 100644
index 00000000..ff38d861
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/empty-cases/expected.txt
@@ -0,0 +1,2 @@
+// {{{
+// }}}
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/empty-cases/input.json b/contribution/generator/tests/TestGeneration/Group/fixtures/empty-cases/input.json
new file mode 100644
index 00000000..337aef58
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/empty-cases/input.json
@@ -0,0 +1,3 @@
+{
+    "cases": []
+}
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/one-case-in-cases/01-start-to-tests.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/one-case-in-cases/01-start-to-tests.txt
new file mode 100644
index 00000000..114726e7
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/one-case-in-cases/01-start-to-tests.txt
@@ -0,0 +1,3 @@
+// {{{
+
+/**
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/one-case-in-cases/02-tests-to-end.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/one-case-in-cases/02-tests-to-end.txt
new file mode 100644
index 00000000..e9b1155b
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/one-case-in-cases/02-tests-to-end.txt
@@ -0,0 +1,3 @@
+}
+
+// }}}
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/one-case-in-cases/input.json b/contribution/generator/tests/TestGeneration/Group/fixtures/one-case-in-cases/input.json
new file mode 100644
index 00000000..0bf974d9
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/one-case-in-cases/input.json
@@ -0,0 +1,13 @@
+{
+    "cases": [
+        {
+            "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+            "description": "some description",
+            "property": "camelCasedProperty",
+            "input": {
+                "camelCasedArgumentName": "maybe any type"
+            },
+            "expected": "maybe any type"
+        }
+    ]
+}
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/one-case-in-cases/tests.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/one-case-in-cases/tests.txt
new file mode 100644
index 00000000..13b552a5
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/one-case-in-cases/tests.txt
@@ -0,0 +1 @@
+ * uuid: 31a673f2-5e54-49fe-bd79-1c1dae476c9c
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/01-start-to-unknown.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/01-start-to-unknown.txt
new file mode 100644
index 00000000..0c8f733f
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/01-start-to-unknown.txt
@@ -0,0 +1,4 @@
+// {{{
+
+/*
+ * Unknown data:
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/02-unknown-to-tests.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/02-unknown-to-tests.txt
new file mode 100644
index 00000000..2362dfda
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/02-unknown-to-tests.txt
@@ -0,0 +1,3 @@
+ */
+
+/**
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/03-tests-to-end.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/03-tests-to-end.txt
new file mode 100644
index 00000000..e9b1155b
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/03-tests-to-end.txt
@@ -0,0 +1,3 @@
+}
+
+// }}}
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/input.json b/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/input.json
new file mode 100644
index 00000000..09067cde
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/input.json
@@ -0,0 +1,14 @@
+{
+    "some-unknown-key": "with value",
+    "cases": [
+        {
+            "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+            "description": "some description",
+            "property": "camelCasedProperty",
+            "input": {
+                "camelCasedArgumentName": "maybe any type"
+            },
+            "expected": "maybe any type"
+        }
+    ]
+}
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/tests.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/tests.txt
new file mode 100644
index 00000000..13b552a5
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/tests.txt
@@ -0,0 +1 @@
+ * uuid: 31a673f2-5e54-49fe-bd79-1c1dae476c9c
diff --git a/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/unknown.txt b/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/unknown.txt
new file mode 100644
index 00000000..77a2ec2a
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Group/fixtures/unknown/unknown.txt
@@ -0,0 +1 @@
+ * {"some-unknown-key":"with value"}
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/InnerGroupTest.php b/contribution/generator/tests/TestGeneration/InnerGroup/InnerGroupTest.php
new file mode 100644
index 00000000..bdda9d9a
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/InnerGroupTest.php
@@ -0,0 +1,168 @@
+<?php
+
+namespace App\Tests\TestGeneration\Group;
+
+use App\Tests\TestGeneration\AssertStringOrder;
+use App\Tests\TestGeneration\ScenarioFixture;
+use App\TrackData\InnerGroup;
+use App\TrackData\Item;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\Attributes\TestDox;
+use PHPUnit\Framework\TestCase as PHPUnitTestCase;
+
+#[TestDox('InnerGroup (App\Tests\TestGeneration\InnerGroup\InnerGroupTest)')]
+final class InnerGroupTest extends PHPUnitTestCase
+{
+    use AssertStringOrder;
+    use ScenarioFixture;
+
+    #[Test]
+    public function implementsItemInterface(): void
+    {
+        $subject = $this->subjectFor('empty-list');
+
+        $this->assertInstanceOf(Item::class, $subject);
+    }
+
+    #[Test]
+    #[TestDox('When given $_dataName, then returns null')]
+    #[DataProvider('nonRenderingScenarios')]
+    public function testNonRenderingScenario(
+        mixed $rawData,
+    ): void {
+        $subject = InnerGroup::from($rawData);
+
+        $this->assertNull($subject);
+    }
+
+    public static function nonRenderingScenarios(): array
+    {
+        // All possible types in JSON, but not array
+        return [
+            'an object' => [ (object)[] ],
+            'a bool' => [ true ],
+            'a string' => [ 'some string' ],
+            'an int' => [ 0 ],
+            'a float' => [ 0.0 ],
+            'null' => [ null ],
+        ];
+    }
+
+    #[Test]
+    #[TestDox('$_dataName')]
+    #[DataProvider('renderingScenarios')]
+    public function testRenderingScenario(
+        string $scenario,
+    ): void {
+        $subject = $this->subjectFor($scenario);
+
+        $actual = $subject->renderPhpCode();
+
+        $this->assertStringContainsAllOfScenario($scenario, $actual);
+    }
+
+    public static function renderingScenarios(): array
+    {
+        return [
+            'When given an empty list, then renders empty string'
+                => [ 'empty-list' ],
+
+            'When given one unknown item in list, then renders unknown item'
+                => [ 'one-unknown-case' ],
+            'When given many unknown items in list, then renders all items'
+                => [ 'many-unknown-cases' ],
+
+            'When given one test case in list, then renders the test case'
+                => [ 'one-test-case' ],
+            'When given many test cases in list, then renders all test cases'
+                => [ 'many-test-cases' ],
+
+            'When given one group in list, then renders the group'
+                => [ 'one-group' ],
+
+            'When given many mixed cases in list, then renders all cases'
+                => [ 'many-mixed-cases' ],
+        ];
+    }
+
+    #[Test]
+    #[TestDox('When given many unknown items in list, then renders the unknown items in order of input')]
+    public function testRenderingUnknownOrder(): void
+    {
+        $subject = $this->subjectFor('many-unknown-cases');
+
+        $actual = $subject->renderPhpCode();
+
+        $this->assertStringContainsStringBeforeString(
+            '"an-unknown-item"',
+            '"another-unknown-item"',
+            $actual,
+        );
+        $this->assertStringContainsStringBeforeString(
+            '"another-unknown-item"',
+            '"a-last-unknown-item"',
+            $actual,
+        );
+    }
+
+    #[Test]
+    #[TestDox('When given many test cases in list, then renders the test cases in order of input')]
+    public function testRenderingTestCaseOrder(): void
+    {
+        $subject = $this->subjectFor('many-test-cases');
+
+        $actual = $subject->renderPhpCode();
+
+        $this->assertStringContainsStringBeforeString(
+            'uuid: 31a673f2-5e54-49fe-bd79-1c1dae476c9c',
+            'uuid: 4f99b933-367b-404b-8c6d-36d5923ee476',
+            $actual,
+        );
+        $this->assertStringContainsStringBeforeString(
+            'uuid: 4f99b933-367b-404b-8c6d-36d5923ee476',
+            'uuid: 91122d10-5ec7-47cb-b759-033756375869',
+            $actual,
+        );
+    }
+
+    #[Test]
+    #[TestDox('When given many mixed items in list, then renders the mixed items in order of input')]
+    public function testRenderingMixedOrder(): void
+    {
+        $subject = $this->subjectFor('many-mixed-cases');
+
+        $actual = $subject->renderPhpCode();
+
+        $this->assertStringContainsStringBeforeString(
+            '"an-unknown-item"',
+            'uuid: 31a673f2-5e54-49fe-bd79-1c1dae476c9c',
+            $actual,
+        );
+        $this->assertStringContainsStringBeforeString(
+            'uuid: 31a673f2-5e54-49fe-bd79-1c1dae476c9c',
+            'section title',
+            $actual,
+        );
+        $this->assertStringContainsStringBeforeString(
+            'section title',
+            'uuid: 4f99b933-367b-404b-8c6d-36d5923ee476',
+            $actual,
+        );
+        $this->assertStringContainsStringBeforeString(
+            'uuid: 4f99b933-367b-404b-8c6d-36d5923ee476',
+            '"also-unknown"',
+            $actual,
+        );
+        $this->assertStringContainsStringBeforeString(
+            '"also-unknown"',
+            'uuid: 91122d10-5ec7-47cb-b759-033756375869',
+            $actual,
+        );
+    }
+
+    private function subjectFor(string $scenario): ?InnerGroup
+    {
+        return InnerGroup::from($this->rawDataFor($scenario));
+    }
+}
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/empty-list/expected.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/empty-list/expected.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/empty-list/input.json b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/empty-list/input.json
new file mode 100644
index 00000000..fe51488c
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/empty-list/input.json
@@ -0,0 +1 @@
+[]
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/01-start-to-first-case.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/01-start-to-first-case.txt
new file mode 100644
index 00000000..2f5e9e36
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/01-start-to-first-case.txt
@@ -0,0 +1,2 @@
+
+/*
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/02-last-case-to-end.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/02-last-case-to-end.txt
new file mode 100644
index 00000000..5c34318c
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/02-last-case-to-end.txt
@@ -0,0 +1 @@
+}
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/first-test-case.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/first-test-case.txt
new file mode 100644
index 00000000..13b552a5
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/first-test-case.txt
@@ -0,0 +1 @@
+ * uuid: 31a673f2-5e54-49fe-bd79-1c1dae476c9c
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/first-unknown.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/first-unknown.txt
new file mode 100644
index 00000000..66962651
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/first-unknown.txt
@@ -0,0 +1 @@
+ * {"an-unknown-item":"will render as multiline comment with JSON"}
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/input.json b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/input.json
new file mode 100644
index 00000000..722a6681
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/input.json
@@ -0,0 +1,40 @@
+[
+    {
+        "an-unknown-item": "will render as multiline comment with JSON"
+    },
+    {
+        "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+        "description": "first description",
+        "property": "someProperty",
+        "input": {
+            "argumentName": []
+        },
+        "expected": true
+    },
+    {
+        "description": "section title",
+        "cases": [
+            {
+                "uuid": "4f99b933-367b-404b-8c6d-36d5923ee476",
+                "description": "second description",
+                "property": "otherProperty",
+                "input": {
+                    "otherArgumentName": [[1, 1]]
+                },
+                "expected": "some value"
+            },
+            {
+                "also-unknown": "resulting in multiline comment with JSON"
+            }
+        ]
+    },
+    {
+        "uuid": "91122d10-5ec7-47cb-b759-033756375869",
+        "description": "and a last one",
+        "property": "alsoProperty",
+        "input": {
+            "argumentName": [[1, 2]]
+        },
+        "expected": [[1, 1, 3]]
+    }
+]
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/last-test-case.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/last-test-case.txt
new file mode 100644
index 00000000..ff6b76a8
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/last-test-case.txt
@@ -0,0 +1 @@
+ * uuid: 91122d10-5ec7-47cb-b759-033756375869
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/second-test-case.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/second-test-case.txt
new file mode 100644
index 00000000..9a88ba7a
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/second-test-case.txt
@@ -0,0 +1 @@
+ * uuid: 4f99b933-367b-404b-8c6d-36d5923ee476
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/second-unknown.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/second-unknown.txt
new file mode 100644
index 00000000..2ab897d3
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-mixed-cases/second-unknown.txt
@@ -0,0 +1 @@
+ * {"also-unknown":"resulting in multiline comment with JSON"}
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/01-start-to-first-test-case.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/01-start-to-first-test-case.txt
new file mode 100644
index 00000000..5a8823e9
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/01-start-to-first-test-case.txt
@@ -0,0 +1,2 @@
+
+/**
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/02-last-test-case-to-end.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/02-last-test-case-to-end.txt
new file mode 100644
index 00000000..5c34318c
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/02-last-test-case-to-end.txt
@@ -0,0 +1 @@
+}
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/first-test.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/first-test.txt
new file mode 100644
index 00000000..13b552a5
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/first-test.txt
@@ -0,0 +1 @@
+ * uuid: 31a673f2-5e54-49fe-bd79-1c1dae476c9c
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/input.json b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/input.json
new file mode 100644
index 00000000..ddc99664
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/input.json
@@ -0,0 +1,29 @@
+[
+    {
+        "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+        "description": "first description",
+        "property": "someProperty",
+        "input": {
+            "argumentName": []
+        },
+        "expected": true
+    },
+    {
+        "uuid": "4f99b933-367b-404b-8c6d-36d5923ee476",
+        "description": "second description",
+        "property": "otherProperty",
+        "input": {
+            "otherArgumentName": [[1, 1]]
+        },
+        "expected": "some value"
+    },
+    {
+        "uuid": "91122d10-5ec7-47cb-b759-033756375869",
+        "description": "and a last one",
+        "property": "alsoProperty",
+        "input": {
+            "argumentName": [[1, 2]]
+        },
+        "expected": [[1, 1, 3]]
+    }
+]
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/last-test.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/last-test.txt
new file mode 100644
index 00000000..ff6b76a8
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/last-test.txt
@@ -0,0 +1 @@
+ * uuid: 91122d10-5ec7-47cb-b759-033756375869
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/second-test.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/second-test.txt
new file mode 100644
index 00000000..9a88ba7a
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-test-cases/second-test.txt
@@ -0,0 +1 @@
+ * uuid: 4f99b933-367b-404b-8c6d-36d5923ee476
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/01-start-to-first-item.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/01-start-to-first-item.txt
new file mode 100644
index 00000000..2f5e9e36
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/01-start-to-first-item.txt
@@ -0,0 +1,2 @@
+
+/*
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/02-last-item-to-end.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/02-last-item-to-end.txt
new file mode 100644
index 00000000..6e0dccf7
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/02-last-item-to-end.txt
@@ -0,0 +1 @@
+ */
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/first-item.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/first-item.txt
new file mode 100644
index 00000000..66962651
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/first-item.txt
@@ -0,0 +1 @@
+ * {"an-unknown-item":"will render as multiline comment with JSON"}
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/input.json b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/input.json
new file mode 100644
index 00000000..8eb83016
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/input.json
@@ -0,0 +1,5 @@
+[
+    { "an-unknown-item": "will render as multiline comment with JSON" },
+    { "another-unknown-item": "will render as multiline comment with JSON" },
+    { "a-last-unknown-item": "will render as multiline comment with JSON" }
+]
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/last-item.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/last-item.txt
new file mode 100644
index 00000000..69ff3b2e
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/last-item.txt
@@ -0,0 +1 @@
+ * {"a-last-unknown-item":"will render as multiline comment with JSON"}
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/second-item.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/second-item.txt
new file mode 100644
index 00000000..efc81dd3
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/many-unknown-cases/second-item.txt
@@ -0,0 +1 @@
+ * {"another-unknown-item":"will render as multiline comment with JSON"}
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-group/expected.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-group/expected.txt
new file mode 100644
index 00000000..1edb9668
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-group/expected.txt
@@ -0,0 +1,3 @@
+
+// {{{
+// }}}
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-group/input.json b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-group/input.json
new file mode 100644
index 00000000..5a69df78
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-group/input.json
@@ -0,0 +1,6 @@
+[
+    {
+        "description": "some description",
+        "cases": []
+    }
+]
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-test-case/expected.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-test-case/expected.txt
new file mode 100644
index 00000000..13b552a5
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-test-case/expected.txt
@@ -0,0 +1 @@
+ * uuid: 31a673f2-5e54-49fe-bd79-1c1dae476c9c
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-test-case/input.json b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-test-case/input.json
new file mode 100644
index 00000000..be200127
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-test-case/input.json
@@ -0,0 +1,11 @@
+[
+    {
+        "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+        "description": "some description",
+        "property": "camelCasedProperty",
+        "input": {
+            "camelCasedArgumentName": "maybe any type"
+        },
+        "expected": "maybe any type"
+    }
+]
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-unknown-case/expected.txt b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-unknown-case/expected.txt
new file mode 100644
index 00000000..45c874c2
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-unknown-case/expected.txt
@@ -0,0 +1,5 @@
+
+/*
+ * Unknown data:
+ * {"an-unknown-item":"will render as multiline comment with JSON"}
+ */
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-unknown-case/input.json b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-unknown-case/input.json
new file mode 100644
index 00000000..bee25c67
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/InnerGroup/fixtures/one-unknown-case/input.json
@@ -0,0 +1,3 @@
+[
+    { "an-unknown-item": "will render as multiline comment with JSON" }
+]
diff --git a/contribution/generator/tests/TestGeneration/ItemFactory/ItemFactoryTest.php b/contribution/generator/tests/TestGeneration/ItemFactory/ItemFactoryTest.php
new file mode 100644
index 00000000..7033fc62
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/ItemFactory/ItemFactoryTest.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Tests\TestGeneration\Group;
+
+use App\Tests\TestGeneration\ScenarioFixture;
+use App\TrackData\CanonicalData;
+use App\TrackData\Group;
+use App\TrackData\InnerGroup;
+use App\TrackData\ItemFactory;
+use App\TrackData\Item;
+use App\TrackData\TestCase;
+use App\TrackData\Unknown;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\Attributes\TestDox;
+use PHPUnit\Framework\TestCase as PHPUnitTestCase;
+
+#[TestDox('ItemFactory (App\Tests\TestGeneration\ItemFactory\ItemFactoryTest)')]
+final class ItemFactoryTest extends PHPUnitTestCase
+{
+    use ScenarioFixture;
+
+    #[Test]
+    #[TestDox('$_dataName')]
+    #[DataProvider('scenarios')]
+    public function detectsExpectedItemType(
+        string $scenario,
+        string $fqcn,
+    ): void {
+        $input = $this->rawDataFor($scenario);
+
+        $subject = new ItemFactory();
+        $actual = $subject->from($input);
+
+        $this->assertInstanceOf(Item::class, $actual);
+        $this->assertInstanceOf($fqcn, $actual);
+    }
+
+    public static function scenarios(): array
+    {
+        return [
+            'When given a minimal valid canonical data object, then produces CanonicalData'
+                => [ 'canonical-data-object-minimal', CanonicalData::class ],
+            'When given a maximal valid canonical data object, then produces CanonicalData'
+                => [ 'canonical-data-object-maximal', CanonicalData::class ],
+            'When given a minimal valid group object, then produces Group'
+                => [ 'group-object-minimal', Group::class ],
+            'When given a maximal valid group object, then produces Group'
+                => [ 'group-object-maximal', Group::class ],
+            'When given an empty array, then produces InnerGroup'
+                => [ 'empty-array', InnerGroup::class ],
+            'When given a non-empty array, then produces InnerGroup'
+                => [ 'non-empty-array', InnerGroup::class ],
+            'When given a minimal valid test case object, then produces TestCase'
+                => [ 'test-case-object-minimal', TestCase::class ],
+            'When given a maximal valid test case object, then produces TestCase'
+                => [ 'test-case-object-maximal', TestCase::class ],
+            'When given an empty object, then produces Unknown'
+                => [ 'empty-object', Unknown::class ],
+        ];
+    }
+}
diff --git a/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/canonical-data-object-maximal/input.json b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/canonical-data-object-maximal/input.json
new file mode 100644
index 00000000..f828ce0e
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/canonical-data-object-maximal/input.json
@@ -0,0 +1,13 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "SomeSolutionClass",
+    "exercise": "some-exercise-slug",
+    "comments": [
+        "One line in comments"
+    ],
+    "cases": [
+        { "some-unknown-item": "some value" }
+    ],
+    "some-unknown-key": "some value"
+}
diff --git a/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/canonical-data-object-minimal/input.json b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/canonical-data-object-minimal/input.json
new file mode 100644
index 00000000..30a4e541
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/canonical-data-object-minimal/input.json
@@ -0,0 +1,5 @@
+{
+    "testClassName": "SomeTestClass",
+    "solutionFileName": "SomeSolutionFile.ext",
+    "solutionClassName": "SomeSolutionClass"
+}
diff --git a/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/empty-array/input.json b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/empty-array/input.json
new file mode 100644
index 00000000..fe51488c
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/empty-array/input.json
@@ -0,0 +1 @@
+[]
diff --git a/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/empty-object/input.json b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/empty-object/input.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/empty-object/input.json
@@ -0,0 +1 @@
+{}
diff --git a/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/group-object-maximal/input.json b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/group-object-maximal/input.json
new file mode 100644
index 00000000..ef07c475
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/group-object-maximal/input.json
@@ -0,0 +1,19 @@
+{
+    "some-unknown-key": "with value",
+    "description": "some title for this group of tests",
+    "comments":[
+        "Some comments",
+        "on multiple lines"
+    ],
+    "cases": [
+        {
+            "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+            "description": "some description",
+            "property": "camelCasedProperty",
+            "input": {
+                "camelCasedArgumentName": "maybe any type"
+            },
+            "expected": "maybe any type"
+        }
+    ]
+}
diff --git a/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/group-object-minimal/input.json b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/group-object-minimal/input.json
new file mode 100644
index 00000000..337aef58
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/group-object-minimal/input.json
@@ -0,0 +1,3 @@
+{
+    "cases": []
+}
diff --git a/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/non-empty-array/input.json b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/non-empty-array/input.json
new file mode 100644
index 00000000..e82af46d
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/non-empty-array/input.json
@@ -0,0 +1,3 @@
+[
+    {}
+]
diff --git a/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/test-case-object-maximal/input.json b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/test-case-object-maximal/input.json
new file mode 100644
index 00000000..110ea7a6
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/test-case-object-maximal/input.json
@@ -0,0 +1,10 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "some description",
+    "property": "camelCasedProperty",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    },
+    "expected": "maybe any type",
+    "some-unknown-property": "This shall show up in DocBlock."
+}
diff --git a/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/test-case-object-minimal/input.json b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/test-case-object-minimal/input.json
new file mode 100644
index 00000000..15ed7d67
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/ItemFactory/fixtures/test-case-object-minimal/input.json
@@ -0,0 +1,9 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "some description",
+    "property": "camelCasedProperty",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    },
+    "expected": "maybe any type"
+}
diff --git a/contribution/generator/tests/TestGeneration/ScenarioFixture.php b/contribution/generator/tests/TestGeneration/ScenarioFixture.php
new file mode 100644
index 00000000..9674ca65
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/ScenarioFixture.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace App\Tests\TestGeneration;
+
+use ReflectionClass;
+
+trait ScenarioFixture
+{
+    private function rawDataFor(string $scenario): mixed
+    {
+        $file = $this->pathToScenarioFixtures($scenario) . '/input.json';
+
+        if (!\file_exists($file)) {
+            $this->fail('Input fixture file of scenario not found: ' . $file);
+        }
+
+        return \json_decode(
+            json: \file_get_contents($file),
+            flags: JSON_THROW_ON_ERROR
+        );
+    }
+
+    private function assertStringContainsAllOfScenario(
+        string $scenario,
+        string $actual,
+        string $message = '',
+    ):void {
+        $scenarioExpectations = \glob(
+            $this->pathToScenarioFixtures($scenario) . '/*.txt'
+        );
+
+        if (empty($scenarioExpectations)) {
+            $this->fail('Scenario ' . $scenario . ' contains no expectation files *.txt');
+        }
+
+        foreach ($scenarioExpectations as $scenarioExpectation) {
+            $this->assertStringContainsString(
+                \file_get_contents($scenarioExpectation),
+                $actual,
+                $message,
+            );
+        }
+    }
+
+    private function pathToScenarioFixtures(string $scenario): string
+    {
+        return $this->pathToFixtures() . '/' . $scenario;
+    }
+
+    private function pathToFixtures(): string
+    {
+        $classReflector = new ReflectionClass($this);
+
+        return \dirname($classReflector->getFileName()) . '/fixtures';
+    }
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/TestCaseTest.php b/contribution/generator/tests/TestGeneration/TestCase/TestCaseTest.php
new file mode 100644
index 00000000..573c4135
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/TestCaseTest.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace App\Tests\TestGeneration\TestCase;
+
+use App\Tests\TestGeneration\AssertStringOrder;
+use App\Tests\TestGeneration\ScenarioFixture;
+use App\TrackData\Item;
+use App\TrackData\TestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\Attributes\TestDox;
+use PHPUnit\Framework\TestCase as PHPUnitTestCase;
+
+#[TestDox('Test Case (App\Tests\TestGeneration\TestCase\TestCaseTest)')]
+final class TestCaseTest extends PHPUnitTestCase
+{
+    use AssertStringOrder;
+    use ScenarioFixture;
+
+    #[Test]
+    public function implementsItemInterface(): void
+    {
+        $subject = $this->subjectFor('non-varying-parts');
+
+        $this->assertInstanceOf(Item::class, $subject);
+    }
+
+    #[Test]
+    #[TestDox('$_dataName')]
+    #[DataProvider('nonRenderingScenarios')]
+    public function testNonRenderingScenario(
+        string $scenario,
+    ): void {
+        $subject = $this->subjectFor($scenario);
+
+        $this->assertNull($subject);
+    }
+
+    public static function nonRenderingScenarios(): array
+    {
+        return [
+            'When given no object, then returns null'
+                => [ 'no-object' ],
+            'When given an empty object, then returns null'
+                => [ 'empty-object' ],
+            'When given object without "uuid", then returns null'
+                => [ 'no-uuid' ],
+            'When given object without "description", then returns null'
+                => [ 'no-description' ],
+            'When given object without "property", then returns null'
+                => [ 'no-property' ],
+            'When given object without "input", then returns null'
+                => [ 'no-input' ],
+            'When given object without "expected", then returns null'
+                => [ 'no-expected' ],
+        ];
+    }
+
+    #[Test]
+    #[TestDox('$_dataName')]
+    #[DataProvider('renderingScenarios')]
+    public function testRenderingScenario(
+        string $scenario,
+    ): void {
+        $subject = $this->subjectFor($scenario);
+
+        $actual = $subject->renderPhpCode();
+
+        $this->assertStringContainsAllOfScenario($scenario, $actual);
+    }
+
+    public static function renderingScenarios(): array
+    {
+        return [
+            // This scenario asserts on the constant parts and their position in relation to the varying part(s)
+            'When given a valid object and an unknown key, then renders all non-varying parts where they belong'
+                => [ 'non-varying-parts' ],
+            // These scenarios assert on the varying part(s)
+            'When given a valid object, then renders uuid'
+                => [ 'uuid' ],
+            'When given a valid object, then renders description as @testdox and method name'
+                => [ 'description' ],
+            'When given a valid object with problematic chars in description, then renders @testdox with and method name without those'
+                => [ 'description-with-problematic-chars' ],
+            'When given a valid object, then renders input object as PHP literal value'
+                => [ 'input' ],
+            'When given no "error" in "expected", then renders "expected" as PHP literal value and asserts on it'
+                => [ 'expect-returned-value' ],
+            'When given "error" in "expected", then renders assertion on expected Exception'
+                => [ 'expect-exception-thrown' ],
+            'When given a different message in "error", then renders that message'
+                => [ 'expect-different-exception-message' ],
+            'When given a valid object, then renders property as method call on subject'
+                => [ 'property' ],
+            'When given a valid object and an unknown key, then renders unknown key as JSON'
+                => [ 'unknown' ],
+            'When given a valid object and no unknown key, then renders no JSON'
+                => [ 'no-unknown' ],
+        ];
+    }
+
+    #[Test]
+    #[TestDox('When given "error" in "expected", then renders the exception expectation before the invocation')]
+    public function renderingExceptionExpectationOrder(): void
+    {
+        $subject = $this->subjectFor('expect-exception-thrown');
+
+        $actual = $subject->renderPhpCode();
+
+        $this->assertStringContainsStringBeforeString(
+            '$this->expectException',
+            '$this->subject->',
+            $actual,
+        );
+    }
+
+    private function subjectFor(string $scenario): ?TestCase
+    {
+        return TestCase::from($this->rawDataFor($scenario));
+    }
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/description-with-problematic-chars/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/description-with-problematic-chars/input.json
new file mode 100644
index 00000000..82878e1f
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/description-with-problematic-chars/input.json
@@ -0,0 +1,9 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "*+^-#.,'`\"<>= so_me *+-,'^`\"#.<>= descri*,'`\"\\+-#.<>=ption *^+-#.with trailing space!<>=,'`\" ",
+    "property": "camelCasedProperty",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    },
+    "expected": "maybe any type"
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/description-with-problematic-chars/method-name.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/description-with-problematic-chars/method-name.txt
new file mode 100644
index 00000000..7edc5408
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/description-with-problematic-chars/method-name.txt
@@ -0,0 +1 @@
+function so_meDescriPtionWithTrailingSpace(
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/description-with-problematic-chars/testdox.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/description-with-problematic-chars/testdox.txt
new file mode 100644
index 00000000..7d7ac72d
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/description-with-problematic-chars/testdox.txt
@@ -0,0 +1 @@
+@testdox *+^-#.,'`"<>= so_me *+-,'^`"#.<>= descri*,'`"\+-#.<>=ption *^+-#.with trailing space!<>=,'`" 
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/description/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/description/input.json
new file mode 100644
index 00000000..15ed7d67
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/description/input.json
@@ -0,0 +1,9 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "some description",
+    "property": "camelCasedProperty",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    },
+    "expected": "maybe any type"
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/description/method-name.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/description/method-name.txt
new file mode 100644
index 00000000..fde2b385
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/description/method-name.txt
@@ -0,0 +1 @@
+function someDescription(
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/description/testdox.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/description/testdox.txt
new file mode 100644
index 00000000..6cdd7f4e
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/description/testdox.txt
@@ -0,0 +1 @@
+@testdox Some description
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/empty-object/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/empty-object/input.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/empty-object/input.json
@@ -0,0 +1 @@
+{}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-different-exception-message/exception-message.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-different-exception-message/exception-message.txt
new file mode 100644
index 00000000..f3a55cf9
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-different-exception-message/exception-message.txt
@@ -0,0 +1 @@
+'another exception message to expect'
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-different-exception-message/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-different-exception-message/input.json
new file mode 100644
index 00000000..9965973e
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-different-exception-message/input.json
@@ -0,0 +1,11 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "some description",
+    "property": "camelCasedProperty",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    },
+    "expected": {
+        "error": "another exception message to expect"
+    }
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-exception-thrown/01-property-to-end.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-exception-thrown/01-property-to-end.txt
new file mode 100644
index 00000000..fbe86480
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-exception-thrown/01-property-to-end.txt
@@ -0,0 +1,2 @@
+    $actual = $this->subject->camelCasedProperty(...$input);
+}
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-exception-thrown/assertion-on-exception.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-exception-thrown/assertion-on-exception.txt
new file mode 100644
index 00000000..c41cd166
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-exception-thrown/assertion-on-exception.txt
@@ -0,0 +1,2 @@
+    $this->expectException(\Exception::class);
+    $this->expectExceptionMessage('some expected exception message');
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-exception-thrown/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-exception-thrown/input.json
new file mode 100644
index 00000000..8bbb8854
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-exception-thrown/input.json
@@ -0,0 +1,11 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "some description",
+    "property": "camelCasedProperty",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    },
+    "expected": {
+        "error": "some expected exception message"
+    }
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-returned-value/assertion-on-expected.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-returned-value/assertion-on-expected.txt
new file mode 100644
index 00000000..399a68e8
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-returned-value/assertion-on-expected.txt
@@ -0,0 +1,2 @@
+    
+    $this->assertSame($expected, $actual);
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-returned-value/assign-expected.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-returned-value/assign-expected.txt
new file mode 100644
index 00000000..4a856aba
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-returned-value/assign-expected.txt
@@ -0,0 +1 @@
+$expected = 'maybe any type';
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-returned-value/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-returned-value/input.json
new file mode 100644
index 00000000..15ed7d67
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/expect-returned-value/input.json
@@ -0,0 +1,9 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "some description",
+    "property": "camelCasedProperty",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    },
+    "expected": "maybe any type"
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/input/expected.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/input/expected.txt
new file mode 100644
index 00000000..dd14ac63
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/input/expected.txt
@@ -0,0 +1,3 @@
+input = array (
+      'camelCasedArgumentName' => 'maybe any type',
+    );
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/input/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/input/input.json
new file mode 100644
index 00000000..15ed7d67
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/input/input.json
@@ -0,0 +1,9 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "some description",
+    "property": "camelCasedProperty",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    },
+    "expected": "maybe any type"
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-description/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-description/input.json
new file mode 100644
index 00000000..443b0e42
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-description/input.json
@@ -0,0 +1,8 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "property": "camelCasedProperty",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    },
+    "expected": "maybe any type"
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-expected/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-expected/input.json
new file mode 100644
index 00000000..6815443f
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-expected/input.json
@@ -0,0 +1,8 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "some description, containing =,-,<,> (unwanted chars)",
+    "property": "camelCasedProperty",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    }
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-input/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-input/input.json
new file mode 100644
index 00000000..214d50b2
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-input/input.json
@@ -0,0 +1,6 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "some description, containing =,-,<,> (unwanted chars)",
+    "property": "camelCasedProperty",
+    "expected": "maybe any type"
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-object/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-object/input.json
new file mode 100644
index 00000000..fe51488c
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-object/input.json
@@ -0,0 +1 @@
+[]
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-property/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-property/input.json
new file mode 100644
index 00000000..e6b93e68
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-property/input.json
@@ -0,0 +1,8 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "some description, containing =,-,<,> (unwanted chars)",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    },
+    "expected": "maybe any type"
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-unknown/01-start-to-uuid.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-unknown/01-start-to-uuid.txt
new file mode 100644
index 00000000..861b5922
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-unknown/01-start-to-uuid.txt
@@ -0,0 +1,2 @@
+/**
+ * uuid: 31a673f2-5e54-49fe-bd79-1c1dae476c9c
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-unknown/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-unknown/input.json
new file mode 100644
index 00000000..15ed7d67
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-unknown/input.json
@@ -0,0 +1,9 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "some description",
+    "property": "camelCasedProperty",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    },
+    "expected": "maybe any type"
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-uuid/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-uuid/input.json
new file mode 100644
index 00000000..7ce624b7
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/no-uuid/input.json
@@ -0,0 +1,8 @@
+{
+    "description": "some description, containing =,-,<,> (unwanted chars)",
+    "property": "camelCasedProperty",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    },
+    "expected": "maybe any type"
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/01-start-to-unknown.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/01-start-to-unknown.txt
new file mode 100644
index 00000000..ce61be1f
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/01-start-to-unknown.txt
@@ -0,0 +1,3 @@
+/**
+ * Unknown data:
+ * {"some-unknown-property":"This shall show up in DocBlock."}
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/02-unknown-to-uuid.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/02-unknown-to-uuid.txt
new file mode 100644
index 00000000..f8b62d07
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/02-unknown-to-uuid.txt
@@ -0,0 +1,3 @@
+ * {"some-unknown-property":"This shall show up in DocBlock."}
+ * 
+ * uuid: 31a673f2-5e54-49fe-bd79-1c1dae476c9c
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/03-uuid-to-testdox.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/03-uuid-to-testdox.txt
new file mode 100644
index 00000000..a999944c
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/03-uuid-to-testdox.txt
@@ -0,0 +1,2 @@
+ * uuid: 31a673f2-5e54-49fe-bd79-1c1dae476c9c
+ * @testdox Some description
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/04-testdox-to-method-name.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/04-testdox-to-method-name.txt
new file mode 100644
index 00000000..763600a4
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/04-testdox-to-method-name.txt
@@ -0,0 +1,4 @@
+ * @testdox Some description
+ * @test
+ */
+public function someDescription(): void
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/05-method-name-to-input.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/05-method-name-to-input.txt
new file mode 100644
index 00000000..596c88b3
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/05-method-name-to-input.txt
@@ -0,0 +1,5 @@
+public function someDescription(): void
+{
+    $this->markTestSkipped('This test has not been verified yet.');
+    
+    $input = array (
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/06-input-to-expected.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/06-input-to-expected.txt
new file mode 100644
index 00000000..e0f96f70
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/06-input-to-expected.txt
@@ -0,0 +1,4 @@
+    $input = array (
+      'camelCasedArgumentName' => 'maybe any type',
+    );
+    $expected = 'maybe any type';
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/07-expected-to-property.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/07-expected-to-property.txt
new file mode 100644
index 00000000..2f7f3e1d
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/07-expected-to-property.txt
@@ -0,0 +1,3 @@
+    $expected = 'maybe any type';
+    
+    $actual = $this->subject->camelCasedProperty(...$input);
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/08-property-to-assertion.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/08-property-to-assertion.txt
new file mode 100644
index 00000000..40eefe70
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/08-property-to-assertion.txt
@@ -0,0 +1,3 @@
+    $actual = $this->subject->camelCasedProperty(...$input);
+    
+    $this->assertSame($expected, $actual);
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/09-assertion-to-end.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/09-assertion-to-end.txt
new file mode 100644
index 00000000..c1f5e546
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/09-assertion-to-end.txt
@@ -0,0 +1,2 @@
+    $this->assertSame($expected, $actual);
+}
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/input.json
new file mode 100644
index 00000000..110ea7a6
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/non-varying-parts/input.json
@@ -0,0 +1,10 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "some description",
+    "property": "camelCasedProperty",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    },
+    "expected": "maybe any type",
+    "some-unknown-property": "This shall show up in DocBlock."
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/property/expected.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/property/expected.txt
new file mode 100644
index 00000000..2a5a98c4
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/property/expected.txt
@@ -0,0 +1 @@
+subject->camelCasedProperty(
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/property/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/property/input.json
new file mode 100644
index 00000000..15ed7d67
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/property/input.json
@@ -0,0 +1,9 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "some description",
+    "property": "camelCasedProperty",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    },
+    "expected": "maybe any type"
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/unknown/expected.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/unknown/expected.txt
new file mode 100644
index 00000000..eaf01902
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/unknown/expected.txt
@@ -0,0 +1 @@
+* {"some-unknown-property":"This shall show up in DocBlock."}
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/unknown/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/unknown/input.json
new file mode 100644
index 00000000..110ea7a6
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/unknown/input.json
@@ -0,0 +1,10 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "some description",
+    "property": "camelCasedProperty",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    },
+    "expected": "maybe any type",
+    "some-unknown-property": "This shall show up in DocBlock."
+}
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/uuid/expected.txt b/contribution/generator/tests/TestGeneration/TestCase/fixtures/uuid/expected.txt
new file mode 100644
index 00000000..7eb65dee
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/uuid/expected.txt
@@ -0,0 +1 @@
+uuid: 31a673f2-5e54-49fe-bd79-1c1dae476c9c
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/TestCase/fixtures/uuid/input.json b/contribution/generator/tests/TestGeneration/TestCase/fixtures/uuid/input.json
new file mode 100644
index 00000000..15ed7d67
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/TestCase/fixtures/uuid/input.json
@@ -0,0 +1,9 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "some description",
+    "property": "camelCasedProperty",
+    "input": {
+        "camelCasedArgumentName": "maybe any type"
+    },
+    "expected": "maybe any type"
+}
diff --git a/contribution/generator/tests/TestGeneration/Unknown/UnknownTest.php b/contribution/generator/tests/TestGeneration/Unknown/UnknownTest.php
new file mode 100644
index 00000000..00ddeb52
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/UnknownTest.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace App\Tests\TestGeneration\Unknown;
+
+use App\Tests\TestGeneration\ScenarioFixture;
+use App\TrackData\Item;
+use App\TrackData\Unknown;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\Attributes\TestDox;
+use PHPUnit\Framework\TestCase as PHPUnitTestCase;
+
+#[TestDox('Unknown (App\Tests\TestGeneration\Unknown\UnknownTest)')]
+final class UnknownTest extends PHPUnitTestCase
+{
+    use ScenarioFixture;
+
+    #[Test]
+    public function implementsItemInterface(): void
+    {
+        $this->assertInstanceOf(Item::class, Unknown::from((object)[]));
+    }
+
+    #[Test]
+    #[TestDox('$_dataName')]
+    #[DataProvider('renderingScenarios')]
+    public function testRenderingScenario(
+        string $scenario,
+    ): void {
+        $subject = $this->subjectFor($scenario);
+
+        $actual = $subject->renderPhpCode();
+
+        $this->assertStringContainsAllOfScenario($scenario, $actual);
+    }
+
+    public static function renderingScenarios(): array
+    {
+        return [
+            // This scenario asserts on the constant parts and their position in relation to the varying part(s)
+            'When given an empty object, then renders multiline comment with JSON'
+                => [ 'non-varying-parts' ],
+            // These scenarios assert on the varying part(s)
+            'When given an empty object, then renders it as JSON for a multiline comment'
+                => [ 'empty-object' ],
+            'When given any object, then renders it as JSON for a multiline comment'
+                => [ 'any-object' ],
+            'When given an array, then renders it as JSON for a multiline comment'
+                => [ 'array' ],
+            'When given a bool, then renders it as JSON for a multiline comment'
+                => [ 'bool' ],
+            'When given a string, then renders it as JSON for a multiline comment'
+                => [ 'string' ],
+            'When given an int, then renders it as JSON for a multiline comment'
+                => [ 'int' ],
+            'When given a float, then renders it as JSON for a multiline comment'
+                => [ 'float' ],
+            'When given null, then renders it as JSON for a multiline comment'
+                => [ 'null' ],
+        ];
+    }
+
+    private function subjectFor(string $scenario): Unknown
+    {
+        return Unknown::from($this->rawDataFor($scenario));
+    }
+}
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/any-object/expected.txt b/contribution/generator/tests/TestGeneration/Unknown/fixtures/any-object/expected.txt
new file mode 100644
index 00000000..3387a2cb
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/any-object/expected.txt
@@ -0,0 +1,2 @@
+ * {"uuid":"31a673f2-5e54-49fe-bd79-1c1dae476c9c","description":"empty input = empty output","property":"canChain","input":{"dominoes":[]},"expected":true}
+ 
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/any-object/input.json b/contribution/generator/tests/TestGeneration/Unknown/fixtures/any-object/input.json
new file mode 100644
index 00000000..751d08b1
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/any-object/input.json
@@ -0,0 +1,9 @@
+{
+    "uuid": "31a673f2-5e54-49fe-bd79-1c1dae476c9c",
+    "description": "empty input = empty output",
+    "property": "canChain",
+    "input": {
+      "dominoes": []
+    },
+    "expected": true
+}
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/array/expected.txt b/contribution/generator/tests/TestGeneration/Unknown/fixtures/array/expected.txt
new file mode 100644
index 00000000..33e48314
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/array/expected.txt
@@ -0,0 +1 @@
+ * []
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/array/input.json b/contribution/generator/tests/TestGeneration/Unknown/fixtures/array/input.json
new file mode 100644
index 00000000..fe51488c
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/array/input.json
@@ -0,0 +1 @@
+[]
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/bool/expected.txt b/contribution/generator/tests/TestGeneration/Unknown/fixtures/bool/expected.txt
new file mode 100644
index 00000000..b308e034
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/bool/expected.txt
@@ -0,0 +1 @@
+ * true
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/bool/input.json b/contribution/generator/tests/TestGeneration/Unknown/fixtures/bool/input.json
new file mode 100644
index 00000000..27ba77dd
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/bool/input.json
@@ -0,0 +1 @@
+true
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/empty-object/expected.txt b/contribution/generator/tests/TestGeneration/Unknown/fixtures/empty-object/expected.txt
new file mode 100644
index 00000000..f28ec520
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/empty-object/expected.txt
@@ -0,0 +1 @@
+ * {}
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/empty-object/input.json b/contribution/generator/tests/TestGeneration/Unknown/fixtures/empty-object/input.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/empty-object/input.json
@@ -0,0 +1 @@
+{}
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/float/expected.txt b/contribution/generator/tests/TestGeneration/Unknown/fixtures/float/expected.txt
new file mode 100644
index 00000000..72c65308
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/float/expected.txt
@@ -0,0 +1 @@
+ * 1.1
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/float/input.json b/contribution/generator/tests/TestGeneration/Unknown/fixtures/float/input.json
new file mode 100644
index 00000000..9459d4ba
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/float/input.json
@@ -0,0 +1 @@
+1.1
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/int/expected.txt b/contribution/generator/tests/TestGeneration/Unknown/fixtures/int/expected.txt
new file mode 100644
index 00000000..72e37e59
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/int/expected.txt
@@ -0,0 +1 @@
+ * 1
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/int/input.json b/contribution/generator/tests/TestGeneration/Unknown/fixtures/int/input.json
new file mode 100644
index 00000000..d00491fd
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/int/input.json
@@ -0,0 +1 @@
+1
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/non-varying-parts/01-start-to-json.txt b/contribution/generator/tests/TestGeneration/Unknown/fixtures/non-varying-parts/01-start-to-json.txt
new file mode 100644
index 00000000..90026c03
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/non-varying-parts/01-start-to-json.txt
@@ -0,0 +1,4 @@
+
+/*
+ * Unknown data:
+ * {}
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/non-varying-parts/02-json-to-end.txt b/contribution/generator/tests/TestGeneration/Unknown/fixtures/non-varying-parts/02-json-to-end.txt
new file mode 100644
index 00000000..7811d8b7
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/non-varying-parts/02-json-to-end.txt
@@ -0,0 +1,2 @@
+ * {}
+ */
\ No newline at end of file
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/non-varying-parts/input.json b/contribution/generator/tests/TestGeneration/Unknown/fixtures/non-varying-parts/input.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/non-varying-parts/input.json
@@ -0,0 +1 @@
+{}
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/null/expected.txt b/contribution/generator/tests/TestGeneration/Unknown/fixtures/null/expected.txt
new file mode 100644
index 00000000..5ca012f7
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/null/expected.txt
@@ -0,0 +1 @@
+ * null
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/null/input.json b/contribution/generator/tests/TestGeneration/Unknown/fixtures/null/input.json
new file mode 100644
index 00000000..19765bd5
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/null/input.json
@@ -0,0 +1 @@
+null
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/string/expected.txt b/contribution/generator/tests/TestGeneration/Unknown/fixtures/string/expected.txt
new file mode 100644
index 00000000..c02c1576
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/string/expected.txt
@@ -0,0 +1 @@
+ * "some string"
diff --git a/contribution/generator/tests/TestGeneration/Unknown/fixtures/string/input.json b/contribution/generator/tests/TestGeneration/Unknown/fixtures/string/input.json
new file mode 100644
index 00000000..e39067c8
--- /dev/null
+++ b/contribution/generator/tests/TestGeneration/Unknown/fixtures/string/input.json
@@ -0,0 +1 @@
+"some string"