bga-dev-skill 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,234 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace BgaHarness;
6
+
7
+ use PDO;
8
+ use PDOStatement;
9
+ use PHPUnit\Framework\Assert;
10
+
11
+ class BgaDatabaseFake
12
+ {
13
+ private PDO $pdo;
14
+
15
+ public function __construct()
16
+ {
17
+ $this->pdo = new PDO('sqlite::memory:');
18
+ $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
19
+ }
20
+
21
+ public function DbQuery(string $sql): void
22
+ {
23
+ $this->pdo->exec($sql);
24
+ }
25
+
26
+ public function getCollectionFromDB(string $sql, bool $bUniqueValue = false): array
27
+ {
28
+ $stmt = $this->query($sql);
29
+ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
30
+
31
+ if (!$bUniqueValue) {
32
+ return $rows;
33
+ }
34
+
35
+ $unique = [];
36
+ foreach ($rows as $row) {
37
+ $values = array_values($row);
38
+ if (count($values) >= 2) {
39
+ $unique[$values[0]] = $values[1];
40
+ }
41
+ }
42
+
43
+ return $unique;
44
+ }
45
+
46
+ public function getObjectFromDB(string $sql): ?array
47
+ {
48
+ $stmt = $this->query($sql);
49
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
50
+
51
+ return $row !== false ? $row : null;
52
+ }
53
+
54
+ public function getUniqueValueFromDB(string $sql): mixed
55
+ {
56
+ $stmt = $this->query($sql);
57
+ $value = $stmt->fetchColumn();
58
+
59
+ return $value === false ? null : $value;
60
+ }
61
+
62
+ public function getIntFromDB(string $sql): int
63
+ {
64
+ return (int) $this->getUniqueValueFromDB($sql);
65
+ }
66
+
67
+ public function seedTable(string $table, array $rows): void
68
+ {
69
+ if ($rows === []) {
70
+ return;
71
+ }
72
+
73
+ $columns = $this->collectColumns($rows);
74
+ $this->ensureTableHasColumns($table, $columns, $rows);
75
+
76
+ $placeholders = implode(', ', array_fill(0, count($columns), '?'));
77
+ $columnsSql = implode(', ', array_map(static fn (string $c): string => '"' . $c . '"', $columns));
78
+ $sql = 'INSERT INTO "' . $table . '" (' . $columnsSql . ') VALUES (' . $placeholders . ')';
79
+
80
+ $stmt = $this->pdo->prepare($sql);
81
+ foreach ($rows as $row) {
82
+ $values = [];
83
+ foreach ($columns as $column) {
84
+ $values[] = $row[$column] ?? null;
85
+ }
86
+ $stmt->execute($values);
87
+ }
88
+ }
89
+
90
+ public function assertRowExists(string $table, array $conditions): void
91
+ {
92
+ [$whereSql, $params] = $this->buildWhere($conditions);
93
+ $sql = 'SELECT COUNT(*) FROM "' . $table . '"' . $whereSql;
94
+
95
+ $stmt = $this->pdo->prepare($sql);
96
+ $stmt->execute($params);
97
+ $count = (int) $stmt->fetchColumn();
98
+
99
+ Assert::assertGreaterThan(
100
+ 0,
101
+ $count,
102
+ sprintf('Expected row was not found in table "%s" for conditions: %s', $table, json_encode($conditions))
103
+ );
104
+ }
105
+
106
+ public function assertRowCount(string $table, int $expected, array $conditions = []): void
107
+ {
108
+ [$whereSql, $params] = $this->buildWhere($conditions);
109
+ $sql = 'SELECT COUNT(*) FROM "' . $table . '"' . $whereSql;
110
+
111
+ $stmt = $this->pdo->prepare($sql);
112
+ $stmt->execute($params);
113
+ $count = (int) $stmt->fetchColumn();
114
+
115
+ Assert::assertSame(
116
+ $expected,
117
+ $count,
118
+ sprintf(
119
+ 'Unexpected row count in table "%s" for conditions %s. Expected %d, got %d.',
120
+ $table,
121
+ json_encode($conditions),
122
+ $expected,
123
+ $count
124
+ )
125
+ );
126
+ }
127
+
128
+ private function query(string $sql): PDOStatement
129
+ {
130
+ $stmt = $this->pdo->query($sql);
131
+ if ($stmt === false) {
132
+ throw new \RuntimeException('Query failed: ' . $sql);
133
+ }
134
+
135
+ return $stmt;
136
+ }
137
+
138
+ private function collectColumns(array $rows): array
139
+ {
140
+ $columns = [];
141
+ foreach ($rows as $row) {
142
+ foreach (array_keys($row) as $column) {
143
+ if (!in_array($column, $columns, true)) {
144
+ $columns[] = $column;
145
+ }
146
+ }
147
+ }
148
+
149
+ return $columns;
150
+ }
151
+
152
+ private function ensureTableHasColumns(string $table, array $columns, array $rows): void
153
+ {
154
+ if (!$this->tableExists($table)) {
155
+ $this->createTable($table, $columns, $rows);
156
+
157
+ return;
158
+ }
159
+
160
+ $existing = $this->listColumns($table);
161
+ foreach ($columns as $column) {
162
+ if (in_array($column, $existing, true)) {
163
+ continue;
164
+ }
165
+ $type = $this->inferColumnType($table, $column, $rows);
166
+ $this->pdo->exec('ALTER TABLE "' . $table . '" ADD COLUMN "' . $column . '" ' . $type);
167
+ }
168
+ }
169
+
170
+ private function createTable(string $table, array $columns, array $rows): void
171
+ {
172
+ $defs = [];
173
+ foreach ($columns as $column) {
174
+ $defs[] = '"' . $column . '" ' . $this->inferColumnType($table, $column, $rows);
175
+ }
176
+ $sql = 'CREATE TABLE IF NOT EXISTS "' . $table . '" (' . implode(', ', $defs) . ')';
177
+ $this->pdo->exec($sql);
178
+ }
179
+
180
+ private function tableExists(string $table): bool
181
+ {
182
+ $stmt = $this->pdo->prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?");
183
+ $stmt->execute([$table]);
184
+
185
+ return (bool) $stmt->fetchColumn();
186
+ }
187
+
188
+ private function listColumns(string $table): array
189
+ {
190
+ $stmt = $this->query('PRAGMA table_info("' . $table . '")');
191
+ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
192
+
193
+ return array_map(static fn (array $row): string => (string) $row['name'], $rows);
194
+ }
195
+
196
+ private function inferColumnType(string $table, string $column, array $rows): string
197
+ {
198
+ if ($column === $table . '_id' || str_ends_with($column, '_id')) {
199
+ return 'INTEGER';
200
+ }
201
+
202
+ foreach ($rows as $row) {
203
+ if (!array_key_exists($column, $row) || $row[$column] === null) {
204
+ continue;
205
+ }
206
+
207
+ if (is_int($row[$column]) || is_bool($row[$column])) {
208
+ return 'INTEGER';
209
+ }
210
+
211
+ if (is_float($row[$column])) {
212
+ return 'REAL';
213
+ }
214
+ }
215
+
216
+ return 'TEXT';
217
+ }
218
+
219
+ private function buildWhere(array $conditions): array
220
+ {
221
+ if ($conditions === []) {
222
+ return ['', []];
223
+ }
224
+
225
+ $clauses = [];
226
+ $params = [];
227
+ foreach ($conditions as $column => $value) {
228
+ $clauses[] = '"' . $column . '" = ?';
229
+ $params[] = $value;
230
+ }
231
+
232
+ return [' WHERE ' . implode(' AND ', $clauses), $params];
233
+ }
234
+ }
@@ -0,0 +1,17 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace BgaHarness;
6
+
7
+ class BgaUserException extends \Exception
8
+ {
9
+ }
10
+
11
+ class BgaVisibleSystemException extends \Exception
12
+ {
13
+ }
14
+
15
+ class BgaSystemException extends \Exception
16
+ {
17
+ }
@@ -0,0 +1,160 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace BgaHarness;
6
+
7
+ use PHPUnit\Framework\Assert;
8
+ use PHPUnit\Framework\TestCase;
9
+
10
+ abstract class BgaGameTestCase extends TestCase
11
+ {
12
+ protected BgaStubs $game;
13
+
14
+ protected function setUp(): void
15
+ {
16
+ parent::setUp();
17
+ $this->game = $this->createGame();
18
+ $this->game->_setPlayers($this->defaultPlayers());
19
+ }
20
+
21
+ abstract protected function createGame(): BgaStubs;
22
+
23
+ protected function givenActivePlayer(int $playerId): static
24
+ {
25
+ $this->game->_setActivePlayer($playerId);
26
+ $this->game->_setCurrentPlayer($playerId);
27
+
28
+ return $this;
29
+ }
30
+
31
+ protected function givenCurrentPlayer(int $playerId): static
32
+ {
33
+ $this->game->_setCurrentPlayer($playerId);
34
+
35
+ return $this;
36
+ }
37
+
38
+ protected function givenState(string $stateName): static
39
+ {
40
+ $this->game->_setState($stateName);
41
+
42
+ return $this;
43
+ }
44
+
45
+ protected function givenDatabaseRows(string $table, array $rows): static
46
+ {
47
+ $this->game->_getDb()->seedTable($table, $rows);
48
+
49
+ return $this;
50
+ }
51
+
52
+ protected function givenGameStateValue(string $name, int $value): static
53
+ {
54
+ $this->game->_setGameStateValue($name, $value);
55
+
56
+ return $this;
57
+ }
58
+
59
+ protected function whenAction(string $method, array $args = []): ActionResult
60
+ {
61
+ try {
62
+ $result = $this->game->$method(...array_values($args));
63
+
64
+ return ActionResult::success($result);
65
+ } catch (BgaUserException $e) {
66
+ return ActionResult::userError($e->getMessage());
67
+ } catch (BgaVisibleSystemException $e) {
68
+ return ActionResult::systemError($e->getMessage());
69
+ }
70
+ }
71
+
72
+ protected function thenStateShouldBe(string $expected): void
73
+ {
74
+ Assert::assertSame($expected, $this->game->_getState());
75
+ }
76
+
77
+ protected function thenNotificationSent(string $type, array $dataSubset = []): void
78
+ {
79
+ $this->game->_getNotifications()->assertNotified($type, $dataSubset);
80
+ }
81
+
82
+ protected function thenNotificationNotSent(string $type): void
83
+ {
84
+ $this->game->_getNotifications()->assertNotNotified($type);
85
+ }
86
+
87
+ protected function thenPlayerNotifiedWith(int $playerId, string $type, array $dataSubset = []): void
88
+ {
89
+ $this->game->_getNotifications()->assertNotifiedPlayer($playerId, $type, $dataSubset);
90
+ }
91
+
92
+ protected function thenDatabaseHas(string $table, array $conditions): void
93
+ {
94
+ $this->game->_getDb()->assertRowExists($table, $conditions);
95
+ }
96
+
97
+ protected function thenDatabaseCount(string $table, int $count, array $conditions = []): void
98
+ {
99
+ $this->game->_getDb()->assertRowCount($table, $count, $conditions);
100
+ }
101
+
102
+ protected function defaultPlayers(): array
103
+ {
104
+ return [
105
+ 1 => ['player_id' => 1, 'player_name' => 'Alice', 'player_score' => 0],
106
+ 2 => ['player_id' => 2, 'player_name' => 'Bob', 'player_score' => 0],
107
+ ];
108
+ }
109
+ }
110
+
111
+ class ActionResult
112
+ {
113
+ public bool $succeeded = false;
114
+ public ?string $errorCode = null;
115
+ public mixed $returnValue = null;
116
+
117
+ public static function success(mixed $value): self
118
+ {
119
+ $result = new self();
120
+ $result->succeeded = true;
121
+ $result->errorCode = null;
122
+ $result->returnValue = $value;
123
+
124
+ return $result;
125
+ }
126
+
127
+ public static function userError(string $code): self
128
+ {
129
+ $result = new self();
130
+ $result->succeeded = false;
131
+ $result->errorCode = $code;
132
+ $result->returnValue = null;
133
+
134
+ return $result;
135
+ }
136
+
137
+ public static function systemError(string $message): self
138
+ {
139
+ $result = new self();
140
+ $result->succeeded = false;
141
+ $result->errorCode = $message;
142
+
143
+ return $result;
144
+ }
145
+
146
+ public function assertSucceeded(): void
147
+ {
148
+ Assert::assertTrue($this->succeeded, 'Expected action to succeed.');
149
+ }
150
+
151
+ public function assertFailedWith(string $errorCode): void
152
+ {
153
+ Assert::assertFalse($this->succeeded, 'Expected action to fail.');
154
+ Assert::assertSame(
155
+ $errorCode,
156
+ $this->errorCode,
157
+ sprintf('Expected failure code "%s", got "%s".', $errorCode, (string) $this->errorCode)
158
+ );
159
+ }
160
+ }
@@ -0,0 +1,116 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace BgaHarness;
6
+
7
+ use PHPUnit\Framework\Assert;
8
+
9
+ class BgaNotificationSpy
10
+ {
11
+ private array $notifications = [];
12
+
13
+ public function notifyAllPlayers(string $type, string $message, array $data): void
14
+ {
15
+ $this->notifications[] = [
16
+ 'target' => 'all',
17
+ 'type' => $type,
18
+ 'message' => $message,
19
+ 'data' => $data,
20
+ ];
21
+ }
22
+
23
+ public function notifyPlayer(int $playerId, string $type, string $message, array $data): void
24
+ {
25
+ $this->notifications[] = [
26
+ 'target' => $playerId,
27
+ 'type' => $type,
28
+ 'message' => $message,
29
+ 'data' => $data,
30
+ ];
31
+ }
32
+
33
+ public function assertNotified(string $type, array $dataSubset = []): void
34
+ {
35
+ foreach ($this->notifications as $notification) {
36
+ if ($notification['type'] !== $type) {
37
+ continue;
38
+ }
39
+ if ($this->containsSubset($notification['data'], $dataSubset)) {
40
+ return;
41
+ }
42
+ }
43
+
44
+ Assert::fail(
45
+ sprintf(
46
+ 'Expected notification "%s" with subset %s was not sent. Captured notifications: %s',
47
+ $type,
48
+ json_encode($dataSubset),
49
+ json_encode($this->notifications)
50
+ )
51
+ );
52
+ }
53
+
54
+ public function assertNotifiedPlayer(int $playerId, string $type, array $dataSubset = []): void
55
+ {
56
+ foreach ($this->notifications as $notification) {
57
+ if ($notification['target'] !== $playerId || $notification['type'] !== $type) {
58
+ continue;
59
+ }
60
+ if ($this->containsSubset($notification['data'], $dataSubset)) {
61
+ return;
62
+ }
63
+ }
64
+
65
+ Assert::fail(
66
+ sprintf(
67
+ 'Expected player %d notification "%s" with subset %s was not sent. Captured notifications: %s',
68
+ $playerId,
69
+ $type,
70
+ json_encode($dataSubset),
71
+ json_encode($this->notifications)
72
+ )
73
+ );
74
+ }
75
+
76
+ public function assertNotNotified(string $type): void
77
+ {
78
+ foreach ($this->notifications as $notification) {
79
+ if ($notification['type'] === $type) {
80
+ Assert::fail(
81
+ sprintf(
82
+ 'Notification "%s" should not have been sent, but was captured: %s',
83
+ $type,
84
+ json_encode($notification)
85
+ )
86
+ );
87
+ }
88
+ }
89
+ }
90
+
91
+ public function assertNotificationCount(int $expected): void
92
+ {
93
+ Assert::assertCount($expected, $this->notifications);
94
+ }
95
+
96
+ public function getNotifications(): array
97
+ {
98
+ return $this->notifications;
99
+ }
100
+
101
+ public function reset(): void
102
+ {
103
+ $this->notifications = [];
104
+ }
105
+
106
+ private function containsSubset(array $actual, array $subset): bool
107
+ {
108
+ foreach ($subset as $key => $value) {
109
+ if (!array_key_exists($key, $actual) || $actual[$key] !== $value) {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ return true;
115
+ }
116
+ }