diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..bc91e4b
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,10 @@
+{
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Edit|Write",
+ "command": "php bin/phpunit 2>&1 | tail -20"
+ }
+ ]
+ }
+}
diff --git a/.gitignore b/.gitignore
index 0857d02..7bb0d35 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@
/var/
/public/bundles/
composer.lock
+/.phpunit.cache
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..b3d3649
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,19 @@
+
+
+
+
+ tests/Unit
+
+
+
+
+
+ src
+
+
+
diff --git a/tests/Unit/Service/SvgRendererTest.php b/tests/Unit/Service/SvgRendererTest.php
new file mode 100644
index 0000000..346f0cb
--- /dev/null
+++ b/tests/Unit/Service/SvgRendererTest.php
@@ -0,0 +1,120 @@
+renderer = new SvgRenderer();
+ }
+
+ #[Test]
+ public function it_returns_a_valid_svg_element(): void
+ {
+ $svg = $this->renderer->render([]);
+
+ $this->assertStringStartsWith('', $svg);
+ }
+
+ #[Test]
+ public function it_includes_accessibility_attributes(): void
+ {
+ $svg = $this->renderer->render([]);
+
+ $this->assertStringContainsString('role="img"', $svg);
+ $this->assertStringContainsString('aria-label="Contribution graph"', $svg);
+ }
+
+ #[Test]
+ public function it_applies_dark_theme_background_color(): void
+ {
+ $svg = $this->renderer->render([], 'dark');
+
+ $this->assertStringContainsString('#0d1117', $svg);
+ }
+
+ #[Test]
+ public function it_applies_light_theme_background_color(): void
+ {
+ $svg = $this->renderer->render([], 'light');
+
+ $this->assertStringContainsString('#ffffff', $svg);
+ }
+
+ #[Test]
+ public function it_falls_back_to_dark_theme_for_unknown_theme_names(): void
+ {
+ $svg = $this->renderer->render([], 'unknown');
+
+ $this->assertStringContainsString('#0d1117', $svg);
+ }
+
+ #[Test]
+ public function it_shows_zero_contributions_when_no_data_is_provided(): void
+ {
+ $svg = $this->renderer->render([]);
+
+ $this->assertStringContainsString('0 contributions in the last year', $svg);
+ }
+
+ #[Test]
+ public function it_displays_formatted_total_contribution_count(): void
+ {
+ $today = (new \DateTimeImmutable('today'))->format('Y-m-d');
+
+ $svg = $this->renderer->render([$today => 1234]);
+
+ $this->assertStringContainsString('1,234 contributions in the last year', $svg);
+ }
+
+ #[Test]
+ public function it_renders_all_53_week_columns(): void
+ {
+ $svg = $this->renderer->render([]);
+
+ // MARGIN_X(28) + col_52 * STEP(13) = 704
+ $this->assertStringContainsString('x="704"', $svg);
+ }
+
+ #[Test]
+ public function it_renders_day_of_week_labels_matching_github(): void
+ {
+ $svg = $this->renderer->render([]);
+
+ $this->assertStringContainsString('>Mon<', $svg);
+ $this->assertStringContainsString('>Wed<', $svg);
+ $this->assertStringContainsString('>Fri<', $svg);
+ }
+
+ #[Test]
+ public function it_renders_a_legend_with_less_and_more_labels(): void
+ {
+ $svg = $this->renderer->render([]);
+
+ $this->assertStringContainsString('>Less<', $svg);
+ $this->assertStringContainsString('>More<', $svg);
+ }
+
+ #[Test]
+ public function it_sums_contributions_from_multiple_dates(): void
+ {
+ $today = (new \DateTimeImmutable('today'))->format('Y-m-d');
+ $yesterday = (new \DateTimeImmutable('yesterday'))->format('Y-m-d');
+
+ $svg = $this->renderer->render([$today => 3, $yesterday => 7]);
+
+ $this->assertStringContainsString('10 contributions in the last year', $svg);
+ }
+}