Compare commits
10 Commits
master
...
48d3ed6b3f
| Author | SHA1 | Date | |
|---|---|---|---|
| 48d3ed6b3f | |||
| d9ac4c3f14 | |||
| d594aa3ec1 | |||
| bf03e10b54 | |||
| f1bda46723 | |||
| dbbde42fe7 | |||
| 7494ec5fda | |||
| c0e5cf79d0 | |||
| a4fcd21492 | |||
| a4611e8718 |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/vendor/
|
||||||
|
/.phpunit.cache/
|
||||||
|
*.sqlite
|
||||||
|
/.publish/
|
||||||
96
Makefile
Normal file
96
Makefile
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# DuckBrain development harness — Makefile
|
||||||
|
#
|
||||||
|
# This file (along with tests/, phpunit.xml, composer.*, .gitignore) lives
|
||||||
|
# ONLY on the develop branch. master is a publish-only vitrine that contains
|
||||||
|
# nothing but the readable artifact: nobody commits to master directly, and
|
||||||
|
# no file outside the WHITELIST below ever reaches it.
|
||||||
|
#
|
||||||
|
# Daily loop:
|
||||||
|
# make test run the unit suite (sqlite :memory:, no containers, no
|
||||||
|
# network; installs dev dependencies first if missing)
|
||||||
|
# ...then develop on develop and commit there as usual.
|
||||||
|
#
|
||||||
|
# Publishing ritual (human only — AI agents must never run it; it commits):
|
||||||
|
# 1. make publish MSG="feat: ..." (MSG optional, defaults to "sync: <date>")
|
||||||
|
# 2. review the new master commit (git log -1 refs/heads/master); if the
|
||||||
|
# generated message needs polish, rewrite it BEFORE pushing, e.g.
|
||||||
|
# from Emacs
|
||||||
|
# 3. git push origin master (manual on purpose: pushing is a
|
||||||
|
# decision, never a side effect)
|
||||||
|
#
|
||||||
|
# How publish builds master — wipe-and-rebuild mirror, no merges, no
|
||||||
|
# cherry-picks (see design decisions behind establish-dev-publish-workflow):
|
||||||
|
# - refuses to run off develop or with a dirty working tree
|
||||||
|
# - checks master out into a disposable worktree under .publish/
|
||||||
|
# (your current working tree is never touched)
|
||||||
|
# - for each WHITELIST path: removes master's copy and checks out develop's,
|
||||||
|
# so deleted/renamed artifact files never survive as ghosts in master
|
||||||
|
# - BLACKLIST then subtracts noise found INSIDE whitelisted paths
|
||||||
|
# - creates the commit only when the artifact actually changed; its body
|
||||||
|
# lists the develop commits (after the last-sync tag) that touched
|
||||||
|
# WHITELIST paths — pure-noise commits are filtered out automatically
|
||||||
|
# - moves the last-sync tag to develop's tip, removes the worktree, and
|
||||||
|
# prints any root entry left out of WHITELIST (add it if it belongs to
|
||||||
|
# the artifact)
|
||||||
|
|
||||||
|
SHELL := /bin/bash
|
||||||
|
|
||||||
|
.PHONY: test publish
|
||||||
|
|
||||||
|
PHPUNIT := vendor/bin/phpunit
|
||||||
|
|
||||||
|
# --- publish configuration -------------------------------------------------
|
||||||
|
WHITELIST := src config.php index.php autoload.php .htaccess readme.org
|
||||||
|
BLACKLIST :=
|
||||||
|
DEVELOP_BRANCH := develop
|
||||||
|
DEVELOP_REF := refs/heads/$(DEVELOP_BRANCH)
|
||||||
|
MASTER_BRANCH := master
|
||||||
|
MASTER_REF := refs/heads/$(MASTER_BRANCH)
|
||||||
|
LAST_SYNC_TAG := last-sync
|
||||||
|
MSG ?= sync: $(shell date +%Y-%m-%d)
|
||||||
|
|
||||||
|
test: $(PHPUNIT)
|
||||||
|
./$(PHPUNIT)
|
||||||
|
|
||||||
|
$(PHPUNIT): composer.json composer.lock
|
||||||
|
composer install --no-interaction
|
||||||
|
|
||||||
|
publish:
|
||||||
|
@if [ "$$(git branch --show-current)" != "$(DEVELOP_BRANCH)" ]; then \
|
||||||
|
echo "ABORT: publish must be invoked from $(DEVELOP_BRANCH) (current: '$$(git branch --show-current)')"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@if [ -n "$$(git status --porcelain)" ]; then \
|
||||||
|
echo "ABORT: working tree is dirty; commit your changes on $(DEVELOP_BRANCH) before publishing:"; \
|
||||||
|
git status --short; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
git worktree remove --force .publish/master 2>/dev/null || true
|
||||||
|
git worktree add --quiet .publish/master $(MASTER_BRANCH)
|
||||||
|
@test "$$(git -C .publish/master symbolic-ref HEAD)" = "$(MASTER_REF)" || { \
|
||||||
|
echo "ABORT: publish worktree HEAD is not attached to $(MASTER_REF)"; \
|
||||||
|
git worktree remove --force .publish/master; exit 1; }
|
||||||
|
@for w in $(WHITELIST); do git -C .publish/master rm -rf --ignore-unmatch --quiet -- $$w; done
|
||||||
|
git -C .publish/master checkout $(DEVELOP_REF) -- $(WHITELIST)
|
||||||
|
@for b in $(BLACKLIST); do git -C .publish/master rm -rf --ignore-unmatch --quiet -- $$b; done
|
||||||
|
git -C .publish/master diff --quiet $(DEVELOP_REF) -- $(WHITELIST)
|
||||||
|
@if git -C .publish/master diff --cached --quiet; then \
|
||||||
|
echo "nothing to publish: master already mirrors $(DEVELOP_BRANCH)"; \
|
||||||
|
else \
|
||||||
|
set -e; \
|
||||||
|
if git rev-parse -q --verify tags/$(LAST_SYNC_TAG) >/dev/null; then \
|
||||||
|
RANGE=$(LAST_SYNC_TAG)..$(DEVELOP_REF); \
|
||||||
|
else \
|
||||||
|
echo "note: no $(LAST_SYNC_TAG) tag yet; summarizing full $(DEVELOP_BRANCH) history"; \
|
||||||
|
RANGE=$(DEVELOP_REF); \
|
||||||
|
fi; \
|
||||||
|
BODY=$$(git log $$RANGE --oneline -- $(WHITELIST)); \
|
||||||
|
{ printf '%s\n' "$(MSG)"; printf '\n'; printf '%s\n' "$$BODY"; } > .publish/msg.txt; \
|
||||||
|
git -C .publish/master commit --quiet -F "$$(pwd)/.publish/msg.txt"; \
|
||||||
|
echo "publish commit created; review/edit it (before pushing) with your git tool of choice"; \
|
||||||
|
fi
|
||||||
|
git tag -f $(LAST_SYNC_TAG) $(DEVELOP_REF) >/dev/null
|
||||||
|
git worktree remove .publish/master
|
||||||
|
@echo "root entries left behind in $(DEVELOP_BRANCH) (add to WHITELIST if they belong to the artifact):"; \
|
||||||
|
comm -23 <(git ls-tree --name-only $(DEVELOP_REF) | sort) \
|
||||||
|
<(for x in $(WHITELIST) $(BLACKLIST); do echo $$x; done | sort) | sed 's/^/ - /'
|
||||||
15
composer.json
Normal file
15
composer.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "kj/duckbrain",
|
||||||
|
"description": "DuckBrain microframework - development harness (tests and tooling; never published to master).",
|
||||||
|
"type": "project",
|
||||||
|
"license": "MIT",
|
||||||
|
"require-dev": {
|
||||||
|
"fakerphp/faker": "^1.24",
|
||||||
|
"phpunit/phpunit": "^11.0"
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1970
composer.lock
generated
Normal file
1970
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
phpunit.xml
Normal file
22
phpunit.xml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="tests/bootstrap.php"
|
||||||
|
colors="true"
|
||||||
|
cacheDirectory=".phpunit.cache"
|
||||||
|
executionOrder="depends,defects"
|
||||||
|
requireCoverageMetadata="false"
|
||||||
|
beStrictAboutOutputDuringTests="true"
|
||||||
|
failOnRisky="true"
|
||||||
|
failOnWarning="true">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Unit">
|
||||||
|
<directory>tests/Unit</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory suffix=".php">src</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
</phpunit>
|
||||||
55
tests/Factories/Factory.php
Normal file
55
tests/Factories/Factory.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Factories;
|
||||||
|
|
||||||
|
use Faker\Factory as FakerFactory;
|
||||||
|
use Faker\Generator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory - DuckBrain testing
|
||||||
|
*
|
||||||
|
* Clase base para crear fábricas de datos de prueba. Cada subclase
|
||||||
|
* define los atributos por defecto de un modelo y construye su
|
||||||
|
* instancia.
|
||||||
|
*
|
||||||
|
* @author KJ
|
||||||
|
* @website https://kj2.me
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
abstract class Factory
|
||||||
|
{
|
||||||
|
protected static ?Generator $fake = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve una instancia de Faker (singleton por fábrica).
|
||||||
|
*/
|
||||||
|
protected static function faker(): Generator
|
||||||
|
{
|
||||||
|
return static::$fake ??= FakerFactory::create();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fusiona los atributos por defecto con los indicados.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected static function attributes(array $attributes = []): array
|
||||||
|
{
|
||||||
|
return array_merge(static::definition(), $attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atributos por defecto de la fábrica.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
abstract protected static function definition(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una instancia del modelo con los atributos dados.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
*/
|
||||||
|
abstract public static function create(array $attributes = []): object;
|
||||||
|
}
|
||||||
61
tests/TestCase.php
Normal file
61
tests/TestCase.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
use Libs\Database;
|
||||||
|
use PDO;
|
||||||
|
use PHPUnit\Framework\TestCase as FrameworkTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TestCase - DuckBrain test harness
|
||||||
|
*
|
||||||
|
* Base class for the framework's own tests. Deliberately lightweight: this
|
||||||
|
* repo has no migrations/ nor duckbrain-commands, so each test defines its
|
||||||
|
* schema with raw DDL through createTable(), and tables registered there are
|
||||||
|
* dropped automatically after the class finishes.
|
||||||
|
*/
|
||||||
|
abstract class TestCase extends FrameworkTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var list<string> Tables created through createTable() for this class.
|
||||||
|
*/
|
||||||
|
private static array $tables = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the PDO connection to the in-memory test database.
|
||||||
|
*
|
||||||
|
* @return PDO
|
||||||
|
*/
|
||||||
|
protected static function db(): PDO
|
||||||
|
{
|
||||||
|
return Database::getInstance(DB_TYPE, DB_HOST, DB_NAME, DB_USER, DB_PASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a table in the test database and registers it for cleanup.
|
||||||
|
*
|
||||||
|
* @param string $name
|
||||||
|
* Table name.
|
||||||
|
*
|
||||||
|
* @param string $columns
|
||||||
|
* Raw column definition, as accepted by the test
|
||||||
|
* engine (sqlite): "id INTEGER PRIMARY KEY, x TEXT".
|
||||||
|
*/
|
||||||
|
protected static function createTable(string $name, string $columns): void
|
||||||
|
{
|
||||||
|
static::db()->exec("CREATE TABLE IF NOT EXISTS {$name} ({$columns})");
|
||||||
|
static::$tables[] = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops every table registered through createTable() during this class.
|
||||||
|
*/
|
||||||
|
public static function tearDownAfterClass(): void
|
||||||
|
{
|
||||||
|
foreach (array_unique(static::$tables) as $table) {
|
||||||
|
static::db()->exec("DROP TABLE IF EXISTS {$table}");
|
||||||
|
}
|
||||||
|
|
||||||
|
static::$tables = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
0
tests/Unit/.gitkeep
Normal file
0
tests/Unit/.gitkeep
Normal file
51
tests/Unit/BootstrapTest.php
Normal file
51
tests/Unit/BootstrapTest.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use Libs\Neuron;
|
||||||
|
use PDO;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BootstrapTest - DuckBrain test harness
|
||||||
|
*
|
||||||
|
* Prueba de humo de la infraestructura: autoload de composer + framework,
|
||||||
|
* constantes de la DB de prueba y conexión PDO realmente utilizable. Si
|
||||||
|
* algo del entorno está mal, esta prueba falla primero con el diagnóstico.
|
||||||
|
*/
|
||||||
|
final class BootstrapTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testFrameworkClassesAreAutoloaded(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(
|
||||||
|
class_exists(Neuron::class),
|
||||||
|
'MISSING AUTOLOAD: autoload.php did not resolve Libs\Neuron; check ROOT_CORE and run from the project root'
|
||||||
|
);
|
||||||
|
|
||||||
|
$neuron = new Neuron(['key' => 'value']);
|
||||||
|
$this->assertSame('value', $neuron->key);
|
||||||
|
$this->assertNull($neuron->doesNotExist);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDatabaseConstantsPointToInMemorySqlite(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(
|
||||||
|
'sqlite',
|
||||||
|
DB_TYPE,
|
||||||
|
'MISSING OVERRIDE: DB_TYPE comes from config.php; the bootstrap must define the DB_* constants BEFORE requiring autoload.php'
|
||||||
|
);
|
||||||
|
$this->assertSame(':memory:', DB_NAME, 'DB_NAME should be :memory: so tests never touch disks or servers');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTestDatabaseIsUsable(): void
|
||||||
|
{
|
||||||
|
$db = self::db();
|
||||||
|
$this->assertInstanceOf(PDO::class, $db, 'Could not obtain the test PDO connection');
|
||||||
|
$this->assertSame($db, self::db(), 'Database::getInstance should return the same singleton');
|
||||||
|
|
||||||
|
$db->exec('CREATE TABLE smoke (id INTEGER PRIMARY KEY, v TEXT)');
|
||||||
|
$db->prepare('INSERT INTO smoke (v) VALUES (?)')->execute(['quack']);
|
||||||
|
$this->assertSame('quack', $db->query('SELECT v FROM smoke')->fetchColumn());
|
||||||
|
$db->exec('DROP TABLE smoke');
|
||||||
|
}
|
||||||
|
}
|
||||||
59
tests/Unit/NeuronTest.php
Normal file
59
tests/Unit/NeuronTest.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use Libs\Neuron;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NeuronTest - DuckBrain test harness
|
||||||
|
*
|
||||||
|
* Regression net para el contenedor de valores del core: construcción
|
||||||
|
* desde array/objeto, propiedades dinámicas y null en inexistentes.
|
||||||
|
*/
|
||||||
|
final class NeuronTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testConstructFromAssociativeArray(): void
|
||||||
|
{
|
||||||
|
$n = new Neuron(['username' => 'kj', 'level' => 3]);
|
||||||
|
|
||||||
|
$this->assertSame('kj', $n->username);
|
||||||
|
$this->assertSame(3, $n->level);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConstructFromObjectCopiesPublicProperties(): void
|
||||||
|
{
|
||||||
|
$source = new \stdClass();
|
||||||
|
$source->id = 7;
|
||||||
|
$source->email = 'kj@example.com';
|
||||||
|
|
||||||
|
$n = new Neuron($source);
|
||||||
|
|
||||||
|
$this->assertSame(7, $n->id);
|
||||||
|
$this->assertSame('kj@example.com', $n->email);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUndefinedPropertyIsNullWithoutNotice(): void
|
||||||
|
{
|
||||||
|
$n = new Neuron();
|
||||||
|
|
||||||
|
$this->assertNull($n->thisDoesNotExist);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDynamicPropertiesCanBeAssignedAndRead(): void
|
||||||
|
{
|
||||||
|
$n = new Neuron();
|
||||||
|
$n->fresh = ['a', 'b'];
|
||||||
|
|
||||||
|
$this->assertSame(['a', 'b'], $n->fresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNestedValuesArePreservedVerbatim(): void
|
||||||
|
{
|
||||||
|
$payload = ['meta' => ['tags' => ['x', 'y'], 'n' => null]];
|
||||||
|
$n = new Neuron($payload);
|
||||||
|
|
||||||
|
$this->assertSame($payload['meta'], $n->meta);
|
||||||
|
$this->assertNull($n->meta['n']);
|
||||||
|
}
|
||||||
|
}
|
||||||
166
tests/Unit/ValidatorTest.php
Normal file
166
tests/Unit/ValidatorTest.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use Libs\Neuron;
|
||||||
|
use Libs\Validator;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ValidatorTest - DuckBrain test harness
|
||||||
|
*
|
||||||
|
* Regression net de las reglas escalares, el batch validateList()
|
||||||
|
* (paro en el primer fallo + $lastFailed) y message(). Las reglas de
|
||||||
|
* archivo (file/image/mimes) requieren fixtures de $_FILES y se dejan
|
||||||
|
* para la suite multi-motor de integración.
|
||||||
|
*/
|
||||||
|
final class ValidatorTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
Validator::$lastFailed = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function scalarRuleProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'valid email' => ['email', 'kj@duckbrain.dev', true],
|
||||||
|
'invalid email' => ['email', 'not-an-email', false],
|
||||||
|
'valid url' => ['url', 'https://kj2.me', true],
|
||||||
|
'invalid url' => ['url', 'kj2.me', false],
|
||||||
|
'int from string' => ['int', '42', true],
|
||||||
|
'int with decimals' => ['int', '42.5', false],
|
||||||
|
'float' => ['float', '3.14', true],
|
||||||
|
'number e-notation' => ['number', '1e3', true],
|
||||||
|
'bool yes' => ['bool', 'yes', true],
|
||||||
|
'bool garbage' => ['bool', 'quizas', false],
|
||||||
|
'strict string' => ['string', '42', true],
|
||||||
|
'string rejects int' => ['string', 42, false],
|
||||||
|
'array' => ['array', [1], true],
|
||||||
|
'array rejects string' => ['array', 'x', false],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('scalarRuleProvider')]
|
||||||
|
public function testScalarRules(string $rule, mixed $value, bool $expected): void
|
||||||
|
{
|
||||||
|
$this->assertSame($expected, Validator::checkRule($value, $rule), "rule {$rule} with " . var_export($value, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExistsAndRequiredDisagreeOnEmptyString(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(Validator::checkRule('', 'exists'));
|
||||||
|
$this->assertFalse(Validator::checkRule('', 'required'));
|
||||||
|
$this->assertFalse(Validator::checkRule(null, 'exists'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSizeRulesMeasureStringNumberAndArray(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(Validator::checkRule('abc', 'min:3'));
|
||||||
|
$this->assertFalse(Validator::checkRule('abc', 'min:4'));
|
||||||
|
$this->assertTrue(Validator::checkRule(10, 'max:10'));
|
||||||
|
$this->assertTrue(Validator::checkRule([1, 2, 3], 'between:2,3'));
|
||||||
|
$this->assertTrue(Validator::checkRule('hey', 'size:3'));
|
||||||
|
$this->assertFalse(Validator::checkRule('hey', 'size:4'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRegexKeepsColonsAndCommasInsidePattern(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(Validator::checkRule('a,b', 'regex:/^a,b$/'));
|
||||||
|
$this->assertFalse(Validator::checkRule('a;b', 'regex:/^a,b$/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnumIsLooseComparison(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(Validator::checkRule('1', 'enum:1,2,3'));
|
||||||
|
$this->assertFalse(Validator::checkRule('9', 'enum:1,2,3'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNotNegatesNextRule(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(Validator::checkRule('3.5', 'not:int'));
|
||||||
|
$this->assertFalse(Validator::checkRule('42', 'not:int'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParseRule(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(['required', ''], Validator::parseRule('required'));
|
||||||
|
$this->assertSame(['enum', 'a,b'], Validator::parseRule('enum:a,b'));
|
||||||
|
$this->assertSame(['regex', '/^a,b$/'], Validator::parseRule('regex:/^a,b$/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateListPassesFullyValidBatch(): void
|
||||||
|
{
|
||||||
|
$data = new Neuron(['username' => 'kj', 'email' => 'kj@duckbrain.dev']);
|
||||||
|
|
||||||
|
$this->assertTrue(Validator::validateList(
|
||||||
|
['username' => 'required|string|min:2', 'email' => 'required|email'],
|
||||||
|
$data
|
||||||
|
));
|
||||||
|
$this->assertSame('', Validator::$lastFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateListStopsAtFirstFailure(): void
|
||||||
|
{
|
||||||
|
$data = new Neuron(['email' => 'not-an-email', 'name' => null]);
|
||||||
|
|
||||||
|
$this->assertFalse(Validator::validateList(
|
||||||
|
['email' => 'required|email', 'name' => 'required'],
|
||||||
|
$data
|
||||||
|
));
|
||||||
|
$this->assertSame('email.email', Validator::$lastFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateListNullableSkipsRestWhenEmpty(): void
|
||||||
|
{
|
||||||
|
$rules = ['bio' => 'nullable|min:10'];
|
||||||
|
|
||||||
|
$this->assertTrue(Validator::validateList($rules, new Neuron(['bio' => null])));
|
||||||
|
$this->assertFalse(Validator::validateList($rules, new Neuron(['bio' => 'too-short'])));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateListConfirmedUsesSiblingField(): void
|
||||||
|
{
|
||||||
|
$rules = ['password' => 'required|confirmed'];
|
||||||
|
|
||||||
|
$ok = new Neuron(['password' => 'secret', 'password_confirmation' => 'secret']);
|
||||||
|
$bad = new Neuron(['password' => 'secret', 'password_confirmation' => 'different']);
|
||||||
|
|
||||||
|
$this->assertTrue(Validator::validateList($rules, $ok));
|
||||||
|
$this->assertFalse(Validator::validateList($rules, $bad));
|
||||||
|
$this->assertSame('password.confirmed', Validator::$lastFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMessageBuildsHumanTextFromLastFailed(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(
|
||||||
|
'The email must be a valid email address.',
|
||||||
|
Validator::message('email.email')
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'The age must be between 5 and 10.',
|
||||||
|
Validator::message('age.between:5,10')
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'The selected status is invalid. Allowed: a, b, c.',
|
||||||
|
Validator::message('status.enum:a,b,c')
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'The reason field is required when mode is other.',
|
||||||
|
Validator::message('reason.required_if:mode,other')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMessageRespectsAttributesAndOverrides(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(
|
||||||
|
'The user name field is required.',
|
||||||
|
Validator::message('username.required', [], ['username' => 'user name'])
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'custom',
|
||||||
|
Validator::message('username.min:3', ['username.min:3' => 'custom'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tests/bootstrap.php
Normal file
16
tests/bootstrap.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Test bootstrap for the DuckBrain development harness.
|
||||||
|
// Run PHPUnit from the project root (autoload.php resolves config.php via cwd).
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
// Define DB constants BEFORE config.php so the test database wins over the
|
||||||
|
// real configuration (config.php's define() warnings are suppressed by @).
|
||||||
|
define('DB_TYPE', 'sqlite');
|
||||||
|
define('DB_HOST', 'localhost');
|
||||||
|
define('DB_NAME', ':memory:');
|
||||||
|
define('DB_USER', '');
|
||||||
|
define('DB_PASS', '');
|
||||||
|
|
||||||
|
@require_once __DIR__ . '/../autoload.php';
|
||||||
Reference in New Issue
Block a user