Skip to content

Commit 9048475

Browse files
committed
Add test coverage for Manifest, ORM, and Service
1 parent dddece3 commit 9048475

16 files changed

+1608
-0
lines changed

CLAUDE.md

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Overview
6+
7+
Silverstripe module that generates YAML fixture files from existing DataObjects in the database. Intended as a developer tool for creating test fixtures and for use with the [Populate](https://github.com/silverstripe/silverstripe-populate) module. Requires Silverstripe CMS 5 and PHP 8.1+.
8+
9+
## Commands
10+
11+
```bash
12+
# Lint (PHP_CodeSniffer with PSR-2 + Slevomat rules)
13+
vendor/bin/phpcs
14+
15+
# Auto-fix lint issues
16+
vendor/bin/phpcbf
17+
18+
# Run tests (must run inside DDEV for database access)
19+
ddev exec vendor/bin/phpunit vendor/chrispenny/silverstripe-data-object-to-fixture/tests/
20+
```
21+
22+
Note: This module lives inside a larger Silverstripe project. Run composer commands from the project root, not from within this package directory.
23+
24+
## Architecture
25+
26+
**FixtureService** (`src/Service/FixtureService.php`) is the public API. It accepts DataObjects via `addDataObject()`, recursively walks all relationships (has_one, has_many, many_many, many_many through), and outputs a sorted YAML fixture via `outputFixture()`.
27+
28+
The processing is stack-based (not recursive): `addDataObject()` pushes related DataObjects onto `$dataObjectStack` and processes them in a loop, preventing deep call stacks.
29+
30+
**Two manifests** track state during processing:
31+
- **FixtureManifest** — maps class names to `Group` objects, each containing `Record` objects (the fixture data for individual DataObjects)
32+
- **RelationshipManifest** — tracks class-to-class dependency edges and exclusions. Uses `KahnSorter` (Kahn's topological sort) to order groups so dependencies appear before dependents in the output YAML
33+
34+
**ORM layer** (`src/ORM/`):
35+
- `Group` — represents a single class in the fixture (keyed by FQCN, contains Records)
36+
- `Record` — represents a single DataObject instance (keyed by ID, contains field key/value pairs)
37+
38+
**Configuration hooks** (via Silverstripe YAML config):
39+
- `exclude_from_fixture_relationships: 1` on a class — omits it entirely from traversal
40+
- `excluded_fixture_relationships` array on a class — omits specific relationship names
41+
- `field_classname_map` on a class — maps polymorphic `has_one` ID fields to the field storing the actual class name (required for `DataObject::class` relationships)
42+
43+
**Dev task** (`src/Task/GenerateFixtureFromDataObject.php`) — web UI at `/dev/tasks/generate-fixture-from-dataobject` for selecting a DataObject class and record, then viewing the generated fixture.
44+
45+
## Coding Standards
46+
47+
PSR-2 base with Slevomat rules. Key exceptions to be aware of:
48+
- Method names can be PascalCase or snake_case (Silverstripe convention)
49+
- `private static` properties are used for Silverstripe config (don't flag as unused)
50+
- Late static binding is allowed
51+
- `new Class()` with parentheses (not `new Class`)
52+
- Null-safe operators (`?->`) are enforced
53+
54+
## Testing
55+
56+
### Setup
57+
58+
Tests use **PHPUnit** via Silverstripe's `SapphireTest` base class, bootstrapped through `vendor/silverstripe/framework/tests/bootstrap.php` (configured in `phpunit.xml.dist`). Run tests from the project root:
59+
60+
```bash
61+
vendor/bin/phpunit
62+
```
63+
64+
### Test directory structure
65+
66+
```
67+
tests/
68+
├── Helper/ # Unit tests for helper classes
69+
├── Manifest/ # Unit tests for manifest classes
70+
├── ORM/ # Unit tests for ORM value objects
71+
├── Service/ # Integration tests for FixtureService
72+
│ └── *.yml # YAML fixture files alongside test classes
73+
└── Mocks/ # Test-only DataObject classes
74+
├── Models/ # Mock DataObjects (has_one targets, children, etc.)
75+
├── Pages/ # Mock Page subclasses (top-level test subjects)
76+
└── Relations/ # Mock junction/through tables for many_many
77+
```
78+
79+
Test class namespace mirrors `src/`: `ChrisPenny\DataObjectToFixture\Tests\{subdirectory}`.
80+
81+
### Writing tests
82+
83+
All test classes extend `SapphireTest`:
84+
85+
```php
86+
use SilverStripe\Dev\SapphireTest;
87+
88+
class FixtureServiceTest extends SapphireTest
89+
{
90+
protected static $fixture_file = 'FixtureServiceTest.yml';
91+
92+
protected static $extra_dataobjects = [
93+
MockPage::class,
94+
MockChild::class,
95+
];
96+
}
97+
```
98+
99+
Key conventions:
100+
- **`$extra_dataobjects`** — register mock DataObject classes so SapphireTest builds their tables in the test database
101+
- **`$fixture_file`** — path to a YAML fixture file (relative to the test class file) that populates the test database before each test
102+
- **`$usesDatabase = true`** — use instead of `$fixture_file` when you need a database but want to create records programmatically
103+
- **`objFromFixture(ClassName::class, 'identifier')`** — retrieve a record loaded from the fixture file
104+
105+
### Mock DataObject classes
106+
107+
Place mock classes in `tests/Mocks/`. Every mock must implement `TestOnly` and declare a unique `$table_name`:
108+
109+
```php
110+
namespace ChrisPenny\DataObjectToFixture\Tests\Mocks\Pages;
111+
112+
use SilverStripe\Dev\TestOnly;
113+
use SilverStripe\ORM\DataObject;
114+
115+
class MockPage extends DataObject implements TestOnly
116+
{
117+
private static string $table_name = 'DOToFixture_MockPage';
118+
119+
private static array $db = [
120+
'Title' => 'Varchar',
121+
];
122+
123+
private static array $has_many = [
124+
'Children' => MockChild::class,
125+
];
126+
}
127+
```
128+
129+
Prefix table names with `DOToFixture_` to avoid collisions with other modules in the test database.
130+
131+
After adding new mock DataObject classes, flush the Silverstripe manifest cache by appending `flush=1` to the test command:
132+
133+
```bash
134+
ddev exec vendor/bin/phpunit vendor/chrispenny/silverstripe-data-object-to-fixture/tests/ '' flush=1
135+
```
136+
137+
### Fixture files (YAML)
138+
139+
Fixture files use fully-qualified class names as keys and `=>ClassName.identifier` syntax for relationships:
140+
141+
```yaml
142+
ChrisPenny\DataObjectToFixture\Tests\Mocks\Pages\MockPage:
143+
page1:
144+
Title: Page 1
145+
Children:
146+
- =>ChrisPenny\DataObjectToFixture\Tests\Mocks\Models\MockChild.child1
147+
148+
ChrisPenny\DataObjectToFixture\Tests\Mocks\Models\MockChild:
149+
child1:
150+
Title: Child 1
151+
```
152+
153+
### Test categories
154+
155+
- **Pure unit tests** (Record, Group, FixtureManifest, KahnSorter) — test logic in isolation, may not need a database at all
156+
- **Integration tests** (FixtureService, RelationshipManifest) — need mock DataObjects in the DB to exercise relationship traversal, config exclusions, and YAML output
157+
- **`GenerateFixtureFromDataObject`** is marked `@codeCoverageIgnore` — skip it
158+
159+
### Testing private methods
160+
161+
Use Reflection when a private method contains logic worth testing directly:
162+
163+
```php
164+
$reflectionClass = new ReflectionClass(FixtureService::class);
165+
$method = $reflectionClass->getMethod('addDataObjectDbFields');
166+
$method->setAccessible(true);
167+
$method->invoke($service, $dataObject);
168+
```
169+
170+
Prefer testing through public methods when practical. Only reach for Reflection when the private method has complex branching that's hard to exercise through the public API alone.

tests/Helper/KahnSorterTest.php

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,164 @@ public function testEmptySort(): void
7070
$this->assertEquals([], $results);
7171
}
7272

73+
public function testCircularDependency(): void
74+
{
75+
$items = [
76+
'A' => ['B'],
77+
'B' => ['A'],
78+
];
79+
80+
$sorter = new KahnSorter();
81+
$results = $sorter->process($items);
82+
83+
// Both nodes should still be in the output
84+
$this->assertContains('A', $results);
85+
$this->assertContains('B', $results);
86+
87+
// Both nodes have 1 left over dependency due to the circular reference
88+
$this->assertEqualsCanonicalizing(
89+
[
90+
'Node `A` has `1` left over dependencies, and so could not be sorted',
91+
'Node `B` has `1` left over dependencies, and so could not be sorted',
92+
],
93+
$sorter->getWarnings()
94+
);
95+
}
96+
97+
public function testSingleNode(): void
98+
{
99+
$items = [
100+
'A' => [],
101+
];
102+
103+
$sorter = new KahnSorter();
104+
$results = $sorter->process($items);
105+
106+
$this->assertSame(['A'], $results);
107+
$this->assertEmpty($sorter->getWarnings());
108+
}
109+
110+
public function testConvergentDependencies(): void
111+
{
112+
// Both A and B depend on C
113+
$items = [
114+
'A' => ['C'],
115+
'B' => ['C'],
116+
'C' => [],
117+
];
118+
119+
$sorter = new KahnSorter();
120+
$results = $sorter->process($items);
121+
122+
// C must appear before both A and B; the relative order of A and B doesn't matter
123+
$cIndex = array_search('C', $results, true);
124+
$aIndex = array_search('A', $results, true);
125+
$bIndex = array_search('B', $results, true);
126+
127+
$this->assertLessThan($aIndex, $cIndex);
128+
$this->assertLessThan($bIndex, $cIndex);
129+
$this->assertSame([], $sorter->getWarnings());
130+
}
131+
132+
public function testLinearChain(): void
133+
{
134+
$items = [
135+
'A' => ['B'],
136+
'B' => ['C'],
137+
'C' => ['D'],
138+
'D' => [],
139+
];
140+
141+
$sorter = new KahnSorter();
142+
$results = $sorter->process($items);
143+
144+
$this->assertSame(['D', 'C', 'B', 'A'], $results);
145+
$this->assertEmpty($sorter->getWarnings());
146+
}
147+
148+
public function testHasProcessed(): void
149+
{
150+
$sorter = new KahnSorter();
151+
152+
$this->assertFalse($sorter->hasProcessed());
153+
154+
$sorter->process([]);
155+
156+
$this->assertTrue($sorter->hasProcessed());
157+
}
158+
159+
public function testGetSortedNodesAfterProcess(): void
160+
{
161+
$sorter = new KahnSorter();
162+
$sorter->process(['A' => ['B'], 'B' => []]);
163+
164+
$this->assertSame(['B', 'A'], $sorter->getSortedNodes());
165+
}
166+
167+
public function testGetMessages(): void
168+
{
169+
$sorter = new KahnSorter();
170+
$sorter->process(['A' => ['B'], 'B' => []]);
171+
172+
$this->assertEqualsCanonicalizing(
173+
[
174+
'[Dependency resolution] A depends on [B]',
175+
'[Dependency resolution] B depends on []',
176+
],
177+
$sorter->getMessages()
178+
);
179+
}
180+
181+
public function testUnspecifiedDependenciesAreCreated(): void
182+
{
183+
// 'B' is referenced as a dependency but not defined as a key
184+
$items = [
185+
'A' => ['B'],
186+
];
187+
188+
$sorter = new KahnSorter();
189+
$results = $sorter->process($items);
190+
191+
// Both should appear in output, with B before A
192+
$this->assertSame(['B', 'A'], $results);
193+
$this->assertEmpty($sorter->getWarnings());
194+
}
195+
196+
public function testThreeNodeCycle(): void
197+
{
198+
$items = [
199+
'A' => ['B'],
200+
'B' => ['C'],
201+
'C' => ['A'],
202+
];
203+
204+
$sorter = new KahnSorter();
205+
$results = $sorter->process($items);
206+
207+
// All nodes are in a cycle, so none can be sorted normally — they're all present but in arbitrary order
208+
$this->assertEqualsCanonicalizing(['A', 'B', 'C'], $results);
209+
210+
$this->assertEqualsCanonicalizing(
211+
[
212+
'Node `A` has `1` left over dependencies, and so could not be sorted',
213+
'Node `B` has `1` left over dependencies, and so could not be sorted',
214+
'Node `C` has `1` left over dependencies, and so could not be sorted',
215+
],
216+
$sorter->getWarnings()
217+
);
218+
}
219+
220+
public function testProcessResetsState(): void
221+
{
222+
$sorter = new KahnSorter();
223+
224+
// First process with warnings
225+
$sorter->process(['A' => ['B'], 'B' => ['A']]);
226+
$this->assertNotEmpty($sorter->getWarnings());
227+
228+
// Second process without warnings — state should be reset
229+
$sorter->process(['A' => ['B'], 'B' => []]);
230+
$this->assertEmpty($sorter->getWarnings());
231+
}
232+
73233
}

0 commit comments

Comments
 (0)