additional, improved transformers / skipIf to eliminate records

This commit is contained in:
Reindl David (IT-PTR-CEN2-SL10)
2026-05-06 23:17:54 +02:00
parent a6ed05a38a
commit ec95ea26c3
15 changed files with 2463 additions and 130 deletions
+219
View File
@@ -435,6 +435,90 @@ class ColumnTransformerTest extends TestCase
$this->assertSame('Hello World', $result['B']);
}
public function testOutputActionAppendWithDelimiter(): void
{
$result = $this->applyOne([
'sourceColumn' => 'A',
'outputColumn' => 'B',
'type' => 'map',
'outputAction' => 'append',
'appendDelimiter' => ', ',
], ['A' => 'World', 'B' => 'Hello']);
$this->assertSame('Hello, World', $result['B']);
}
public function testOutputActionAppendWithDelimiterSkippedWhenTargetEmpty(): void
{
$result = $this->applyOne([
'sourceColumn' => 'A',
'outputColumn' => 'B',
'type' => 'map',
'outputAction' => 'append',
'appendDelimiter' => ', ',
], ['A' => 'Hello', 'B' => '']);
$this->assertSame('Hello', $result['B']);
}
public function testOutputActionAppendLine(): void
{
$result = $this->applyOne([
'sourceColumn' => 'A',
'outputColumn' => 'B',
'type' => 'map',
'outputAction' => 'append-line',
], ['A' => 'Line2', 'B' => 'Line1']);
$this->assertSame("Line1\nLine2", $result['B']);
}
public function testOutputActionAppendLineNoLeadingNewlineWhenEmpty(): void
{
$result = $this->applyOne([
'sourceColumn' => 'A',
'outputColumn' => 'B',
'type' => 'map',
'outputAction' => 'append-line',
], ['A' => 'Line1', 'B' => '']);
$this->assertSame('Line1', $result['B']);
}
public function testOutputActionOverwriteIfEmpty(): void
{
$resultEmpty = $this->applyOne([
'sourceColumn' => 'A',
'outputColumn' => 'B',
'type' => 'map',
'outputAction' => 'overwrite-if-empty',
], ['A' => 'new', 'B' => '']);
$this->assertSame('new', $resultEmpty['B']);
$resultFilled = $this->applyOne([
'sourceColumn' => 'A',
'outputColumn' => 'B',
'type' => 'map',
'outputAction' => 'overwrite-if-empty',
], ['A' => 'new', 'B' => 'existing']);
$this->assertSame('existing', $resultFilled['B']);
}
public function testOutputActionOverwriteIfNotEmpty(): void
{
$resultNotEmpty = $this->applyOne([
'sourceColumn' => 'A',
'outputColumn' => 'B',
'type' => 'map',
'outputAction' => 'overwrite-if-not-empty',
], ['A' => 'new', 'B' => 'old']);
$this->assertSame('new', $resultNotEmpty['B']);
$resultEmpty = $this->applyOne([
'sourceColumn' => 'A',
'outputColumn' => 'B',
'type' => 'map',
'outputAction' => 'overwrite-if-not-empty',
], ['A' => '', 'B' => 'old']);
$this->assertSame('old', $resultEmpty['B']);
}
// -------------------------------------------------------------------------
// multi-output split
// -------------------------------------------------------------------------
@@ -504,4 +588,139 @@ class ColumnTransformerTest extends TestCase
$transformer->transformRow(['A' => '1', 'B' => '2', 'C' => '3']);
$this->assertSame(2, $transformer->getOutputColumns());
}
// -------------------------------------------------------------------------
// timeperiod
// -------------------------------------------------------------------------
/** @var array<int, array<string, string>> */
private array $testPeriods = [
['from' => '04:00:00', 'to' => '08:59:59', 'label' => 'Morgen'],
['from' => '09:00:00', 'to' => '10:59:59', 'label' => 'Vormittag'],
['from' => '11:00:00', 'to' => '13:59:59', 'label' => 'Mittag'],
['from' => '14:00:00', 'to' => '17:59:59', 'label' => 'Nachmittag'],
['from' => '18:00:00', 'to' => '21:59:59', 'label' => 'Abend'],
['from' => '22:00:00', 'to' => '03:59:59', 'label' => 'Nacht'],
];
public function testTimePeriodBasicMapping(): void
{
$result = $this->applyOne([
'sourceColumn' => 'Time',
'outputColumn' => 'Period',
'type' => 'timeperiod',
'timeFormat' => 'H:i:s',
'periods' => $this->testPeriods,
'default' => '',
], ['Time' => '09:30:00', 'Period' => '']);
$this->assertSame('Vormittag', $result['Period']);
}
public function testTimePeriodMidnightSpanning(): void
{
$result1 = $this->applyOne([
'sourceColumn' => 'Time',
'outputColumn' => 'Period',
'type' => 'timeperiod',
'timeFormat' => 'H:i:s',
'periods' => $this->testPeriods,
'default' => '',
], ['Time' => '23:00:00', 'Period' => '']);
$this->assertSame('Nacht', $result1['Period']);
$result2 = $this->applyOne([
'sourceColumn' => 'Time',
'outputColumn' => 'Period',
'type' => 'timeperiod',
'timeFormat' => 'H:i:s',
'periods' => $this->testPeriods,
'default' => '',
], ['Time' => '02:00:00', 'Period' => '']);
$this->assertSame('Nacht', $result2['Period']);
}
public function testTimePeriodNoMatch(): void
{
// 03:45 falls outside all labelled ranges except Nacht (00:00-03:59)
$result = $this->applyOne([
'sourceColumn' => 'Time',
'outputColumn' => 'Period',
'type' => 'timeperiod',
'timeFormat' => 'H:i:s',
'periods' => [
['from' => '09:00:00', 'to' => '17:59:59', 'label' => 'Day'],
],
'default' => 'Unknown',
], ['Time' => '03:45:00', 'Period' => '']);
$this->assertSame('Unknown', $result['Period']);
}
public function testTimePeriodInvalidInput(): void
{
$result = $this->applyOne([
'sourceColumn' => 'Time',
'outputColumn' => 'Period',
'type' => 'timeperiod',
'timeFormat' => 'H:i:s',
'periods' => $this->testPeriods,
'default' => 'N/A',
], ['Time' => '', 'Period' => '']);
$this->assertSame('N/A', $result['Period']);
}
// -------------------------------------------------------------------------
// ucwordsfirst guard
// -------------------------------------------------------------------------
public function testUcwordsFirstSkipsLowercase(): void
{
// Input already contains lowercase letters → must be returned unchanged
$result = $this->applyOne([
'sourceColumn' => 'A',
'outputColumn' => 'A',
'type' => 'ucwordsfirst',
], ['A' => 'Coop pronto chur']);
$this->assertSame('Coop pronto chur', $result['A']);
}
public function testUcwordsFirstAppliesAllCaps(): void
{
// Fully uppercase input → capitalise first letter of each word
$result = $this->applyOne([
'sourceColumn' => 'A',
'outputColumn' => 'A',
'type' => 'ucwordsfirst',
], ['A' => 'COOP PRONTO']);
$this->assertSame('Coop Pronto', $result['A']);
}
// -------------------------------------------------------------------------
// append-if-not-empty
// -------------------------------------------------------------------------
public function testAppendIfNotEmptySkipsEmpty(): void
{
// Result is empty → target column must remain unchanged
$result = $this->applyOne([
'sourceColumn' => 'A',
'outputColumn' => 'B',
'type' => 'map',
'outputAction' => 'append-if-not-empty',
'appendDelimiter' => ' ',
], ['A' => '', 'B' => 'existing']);
$this->assertSame('existing', $result['B']);
}
public function testAppendIfNotEmptyAppendsNonEmpty(): void
{
// Non-empty result → appended with delimiter
$result = $this->applyOne([
'sourceColumn' => 'A',
'outputColumn' => 'B',
'type' => 'map',
'outputAction' => 'append-if-not-empty',
'appendDelimiter' => ' ',
], ['A' => 'new', 'B' => 'existing']);
$this->assertSame('existing new', $result['B']);
}
}
+414
View File
@@ -0,0 +1,414 @@
<?php
namespace UbsCsvTransformer\Tests;
use PHPUnit\Framework\TestCase;
use UbsCsvTransformer\FireflyImporter;
use UbsCsvTransformer\DebugLogger;
/**
* Tests for the chunked-import state file / resume feature.
*
* Strategy: subclass FireflyImporter and override import() so no real HTTP or
* CLI call is made. The override is configured per test via a callable queue.
*/
class FireflyImporterChunkStateTest extends TestCase
{
/** @var string Temporary directory for CSV and state files */
private string $tmpDir;
/** @var string Path to a throwaway JSON config file */
private string $jsonConfig;
protected function setUp(): void
{
DebugLogger::reset();
$this->tmpDir = sys_get_temp_dir() . '/ffi_state_test_' . uniqid('', true);
mkdir($this->tmpDir, 0700, true);
// Minimal Firefly importer config (format v3)
$configData = [
'version' => 3,
'flow' => 'csv',
'roles' => ['amount'],
'default_account' => 1,
];
$this->jsonConfig = $this->tmpDir . '/ff-config.json';
file_put_contents($this->jsonConfig, json_encode($configData));
}
protected function tearDown(): void
{
// Remove all temp files
foreach (glob($this->tmpDir . '/*') ?: [] as $f) {
@unlink($f);
}
@rmdir($this->tmpDir);
}
// ─── Helpers ─────────────────────────────────────────────────────────────
/**
* Creates an importer stub whose import() calls return results from $queue
* in order. Each element of the queue is either true (success) or false (failure).
*
* @param array<bool> $importResultQueue
* @param int $chunkSize
*/
private function makeImporter(array $importResultQueue, int $chunkSize): FireflyImporter
{
$config = [
'mode' => 'http',
'importerUrl' => 'https://example.com',
'accessToken' => 'test-secret-1234567',
'personalSecret' => 'test-pat',
'jsonConfig' => $this->jsonConfig,
'chunkSize' => $chunkSize,
];
$queue = $importResultQueue;
return new class ($config, $queue) extends FireflyImporter {
/** @var array<bool> */
private array $queue;
/** @param array<bool> $queue */
public function __construct(array $config, array $queue)
{
parent::__construct($config);
$this->queue = $queue;
}
public function import(string $csvFile): array
{
$success = array_shift($this->queue) ?? true;
if ($success) {
return [
'success' => true,
'exit_code' => 200,
'output' => ['stdout' => '', 'stderr' => ''],
'duration' => 1.0,
'csv_file' => $csvFile,
'summary' => [
'completed' => true,
'created' => 1,
'by_type' => ['withdrawal' => 1],
'duplicates' => 0,
'errors' => [],
],
];
}
return [
'success' => false,
'error' => 'Simulated failure',
'output' => ['stdout' => '', 'stderr' => ''],
'exit_code' => 500,
];
}
};
}
/**
* Writes a CSV with $dataRows data rows (each row has two columns).
*/
private function writeCsv(string $path, int $dataRows): void
{
$fp = fopen($path, 'w');
assert($fp !== false);
fputcsv($fp, ['col_a', 'col_b'], ',', '"', '\\');
for ($i = 1; $i <= $dataRows; $i++) {
fputcsv($fp, ["val_a_{$i}", "val_b_{$i}"], ',', '"', '\\');
}
fclose($fp);
}
private function stateFile(string $csvPath): string
{
return $csvPath . '.ffi-state.json';
}
// ─── Tests ───────────────────────────────────────────────────────────────
/**
* When chunkSize is 0, import() is used directly — no state file should appear.
*/
public function testNoStateFileWhenChunkingNotUsed(): void
{
$csv = $this->tmpDir . '/test.csv';
$this->writeCsv($csv, 5);
$importer = $this->makeImporter([true], 0);
$result = $importer->importChunked($csv);
$this->assertTrue($result['success']);
$this->assertFileDoesNotExist($this->stateFile($csv));
}
/**
* When the file has fewer rows than chunkSize, no chunking occurs — no state file.
*/
public function testNoStateFileWhenRowsBelowChunkSize(): void
{
$csv = $this->tmpDir . '/test.csv';
$this->writeCsv($csv, 3);
$importer = $this->makeImporter([true], 10);
$importer->importChunked($csv);
$this->assertFileDoesNotExist($this->stateFile($csv));
}
/**
* After chunk 1 of 3 fails, the state file must exist and record 0 completed chunks.
*/
public function testStateFileCreatedOnFirstChunkFailure(): void
{
$csv = $this->tmpDir . '/test.csv';
$this->writeCsv($csv, 9); // 3 chunks of 3
// Chunk 1 fails immediately
$importer = $this->makeImporter([false], 3);
$result = $importer->importChunked($csv);
$this->assertFalse($result['success']);
$this->assertFileExists($this->stateFile($csv));
/** @var array<string, mixed> $state */
$state = json_decode((string) file_get_contents($this->stateFile($csv)), true);
$this->assertSame([], $state['completed_chunks']);
}
/**
* After chunks 1 and 2 succeed but chunk 3 fails, the state file records [0, 1].
*/
public function testStateFileRecordsCompletedChunksOnPartialFailure(): void
{
$csv = $this->tmpDir . '/test.csv';
$this->writeCsv($csv, 9); // 3 chunks of 3
// Chunks 0, 1 succeed; chunk 2 fails
$importer = $this->makeImporter([true, true, false], 3);
$result = $importer->importChunked($csv);
$this->assertFalse($result['success']);
$this->assertFileExists($this->stateFile($csv));
/** @var array<string, mixed> $state */
$state = json_decode((string) file_get_contents($this->stateFile($csv)), true);
$this->assertSame([0, 1], $state['completed_chunks']);
$this->assertArrayHasKey('0', $state['chunk_results']);
$this->assertArrayHasKey('1', $state['chunk_results']);
}
/**
* After full success the state file is deleted automatically.
*/
public function testStateFileDeletedAfterFullSuccess(): void
{
$csv = $this->tmpDir . '/test.csv';
$this->writeCsv($csv, 6); // 2 chunks of 3
$importer = $this->makeImporter([true, true], 3);
$result = $importer->importChunked($csv);
$this->assertTrue($result['success']);
$this->assertFileDoesNotExist($this->stateFile($csv));
}
/**
* On a second run with an existing state showing [0, 1] done, only chunk 2
* (index 2) should call import() — i.e., exactly one call is made.
*/
public function testResumeSkipsAlreadyCompletedChunks(): void
{
$csv = $this->tmpDir . '/test.csv';
$this->writeCsv($csv, 9); // 3 chunks of 3
// ── First run: chunks 0+1 succeed, chunk 2 fails ────────────────────
$run1 = $this->makeImporter([true, true, false], 3);
$run1->importChunked($csv);
$this->assertFileExists($this->stateFile($csv));
// ── Second run: only chunk 2 should be attempted ────────────────────
// We record how many times import() is actually called via a counting wrapper
$counter = new \stdClass();
$counter->value = 0;
$config = [
'mode' => 'http',
'importerUrl' => 'https://example.com',
'accessToken' => 'test-secret-1234567',
'personalSecret' => 'test-pat',
'jsonConfig' => $this->jsonConfig,
'chunkSize' => 3,
];
$run2 = new class ($config, $counter) extends FireflyImporter {
private \stdClass $counter;
public function __construct(array $config, \stdClass $counter)
{
parent::__construct($config);
$this->counter = $counter;
}
public function import(string $csvFile): array
{
$this->counter->value++;
return [
'success' => true,
'exit_code' => 200,
'output' => ['stdout' => '', 'stderr' => ''],
'duration' => 1.0,
'csv_file' => $csvFile,
'summary' => [
'completed' => true,
'created' => 1,
'by_type' => ['withdrawal' => 1],
'duplicates' => 0,
'errors' => [],
],
];
}
};
$result2 = $run2->importChunked($csv);
$this->assertTrue($result2['success']);
$this->assertSame(1, $counter->value, 'Only the 1 remaining chunk (index 2) should be imported');
$this->assertFileDoesNotExist($this->stateFile($csv), 'State file must be deleted after full success');
}
/**
* A state file whose total_rows does not match the current CSV is silently
* discarded and a fresh import is started.
*/
public function testStaleMismatchedStateIsIgnored(): void
{
$csv = $this->tmpDir . '/test.csv';
$this->writeCsv($csv, 9); // 3 chunks of 3
// Plant a stale state file with a wrong total_rows
$staleState = [
'csv_file' => realpath($csv) ?: $csv,
'total_rows' => 99, // wrong
'chunk_size' => 3,
'total_chunks' => 3,
'completed_chunks' => [0, 1],
'chunk_results' => [],
'created_at' => '2020-01-01T00:00:00+00:00',
'updated_at' => '2020-01-01T00:00:00+00:00',
];
file_put_contents($this->stateFile($csv), json_encode($staleState));
// All 3 chunks should be called (fresh start despite stale state)
$counter = new \stdClass();
$counter->value = 0;
$config = [
'mode' => 'http',
'importerUrl' => 'https://example.com',
'accessToken' => 'test-secret-1234567',
'personalSecret' => 'test-pat',
'jsonConfig' => $this->jsonConfig,
'chunkSize' => 3,
];
$importer = new class ($config, $counter) extends FireflyImporter {
private \stdClass $counter;
public function __construct(array $config, \stdClass $counter)
{
parent::__construct($config);
$this->counter = $counter;
}
public function import(string $csvFile): array
{
$this->counter->value++;
return [
'success' => true,
'exit_code' => 200,
'output' => ['stdout' => '', 'stderr' => ''],
'duration' => 1.0,
'csv_file' => $csvFile,
'summary' => [
'completed' => true,
'created' => 1,
'by_type' => ['withdrawal' => 1],
'duplicates' => 0,
'errors' => [],
],
];
}
};
$result = $importer->importChunked($csv);
$this->assertTrue($result['success']);
$this->assertSame(3, $counter->value, 'All 3 chunks must be imported when stale state is discarded');
}
/**
* A corrupt (non-JSON) state file is silently discarded; no exception is thrown.
*/
public function testCorruptStateFileIsIgnored(): void
{
$csv = $this->tmpDir . '/test.csv';
$this->writeCsv($csv, 6); // 2 chunks of 3
file_put_contents($this->stateFile($csv), '{this is not valid json!!!}');
$importer = $this->makeImporter([true, true], 3);
$result = $importer->importChunked($csv);
$this->assertTrue($result['success']);
}
/**
* resetImportState() deletes an existing state file.
*/
public function testResetImportStateClearsStateFile(): void
{
$csv = $this->tmpDir . '/test.csv';
$this->writeCsv($csv, 9);
// Plant a state file
file_put_contents($this->stateFile($csv), '{}');
$this->assertFileExists($this->stateFile($csv));
$importer = $this->makeImporter([], 3);
$importer->resetImportState($csv);
$this->assertFileDoesNotExist($this->stateFile($csv));
}
/**
* hasResumeState() returns false when no state file is present.
*/
public function testHasResumeStateReturnsFalseWithoutStateFile(): void
{
$csv = $this->tmpDir . '/test.csv';
$this->writeCsv($csv, 9);
$importer = $this->makeImporter([], 3);
$this->assertFalse($importer->hasResumeState($csv));
}
/**
* hasResumeState() returns true after a partial failure creates a valid state file.
*/
public function testHasResumeStateReturnsTrueAfterPartialFailure(): void
{
$csv = $this->tmpDir . '/test.csv';
$this->writeCsv($csv, 9); // 3 chunks of 3
$importer = $this->makeImporter([true, false], 3); // chunk 2 (index 1) fails
$importer->importChunked($csv);
$importer2 = $this->makeImporter([], 3);
$this->assertTrue($importer2->hasResumeState($csv));
}
}
+255
View File
@@ -0,0 +1,255 @@
<?php
namespace UbsCsvTransformer\Tests;
use PHPUnit\Framework\TestCase;
use UbsCsvTransformer\RowFilter;
class RowFilterTest extends TestCase
{
// -------------------------------------------------------------------------
// Leaf-condition operators
// -------------------------------------------------------------------------
public function testEmptyOperator(): void
{
$this->assertTrue(RowFilter::evaluate(
['column' => 'A', 'operator' => 'empty'],
['A' => '']
));
$this->assertFalse(RowFilter::evaluate(
['column' => 'A', 'operator' => 'empty'],
['A' => 'something']
));
}
public function testNotEmptyOperator(): void
{
$this->assertTrue(RowFilter::evaluate(
['column' => 'A', 'operator' => 'not-empty'],
['A' => 'value']
));
$this->assertFalse(RowFilter::evaluate(
['column' => 'A', 'operator' => 'not-empty'],
['A' => '']
));
}
public function testEqualsOperator(): void
{
$this->assertTrue(RowFilter::evaluate(
['column' => 'A', 'operator' => 'equals', 'value' => 'hello'],
['A' => 'hello']
));
$this->assertFalse(RowFilter::evaluate(
['column' => 'A', 'operator' => 'equals', 'value' => 'hello'],
['A' => 'world']
));
}
public function testNotEqualsOperator(): void
{
$this->assertTrue(RowFilter::evaluate(
['column' => 'A', 'operator' => 'not-equals', 'value' => 'hello'],
['A' => 'world']
));
$this->assertFalse(RowFilter::evaluate(
['column' => 'A', 'operator' => 'not-equals', 'value' => 'hello'],
['A' => 'hello']
));
}
public function testContainsOperator(): void
{
$this->assertTrue(RowFilter::evaluate(
['column' => 'A', 'operator' => 'contains', 'value' => 'foo'],
['A' => 'foobar']
));
$this->assertFalse(RowFilter::evaluate(
['column' => 'A', 'operator' => 'contains', 'value' => 'baz'],
['A' => 'foobar']
));
}
public function testNotContainsOperator(): void
{
$this->assertTrue(RowFilter::evaluate(
['column' => 'A', 'operator' => 'not-contains', 'value' => 'baz'],
['A' => 'foobar']
));
$this->assertFalse(RowFilter::evaluate(
['column' => 'A', 'operator' => 'not-contains', 'value' => 'foo'],
['A' => 'foobar']
));
}
public function testMatchesOperator(): void
{
$this->assertTrue(RowFilter::evaluate(
['column' => 'A', 'operator' => 'matches', 'pattern' => '^\d{4}$'],
['A' => '1234']
));
$this->assertFalse(RowFilter::evaluate(
['column' => 'A', 'operator' => 'matches', 'pattern' => '^\d{4}$'],
['A' => 'abcd']
));
}
public function testNotMatchesOperator(): void
{
$this->assertTrue(RowFilter::evaluate(
['column' => 'A', 'operator' => 'not-matches', 'pattern' => '^\d{4}$'],
['A' => 'abcd']
));
$this->assertFalse(RowFilter::evaluate(
['column' => 'A', 'operator' => 'not-matches', 'pattern' => '^\d{4}$'],
['A' => '1234']
));
}
public function testGtOperator(): void
{
$this->assertTrue(RowFilter::evaluate(
['column' => 'Amount', 'operator' => 'gt', 'value' => '100'],
['Amount' => '150.50']
));
$this->assertFalse(RowFilter::evaluate(
['column' => 'Amount', 'operator' => 'gt', 'value' => '100'],
['Amount' => '50']
));
}
public function testGteOperator(): void
{
$this->assertTrue(RowFilter::evaluate(
['column' => 'Amount', 'operator' => 'gte', 'value' => '100'],
['Amount' => '100']
));
$this->assertFalse(RowFilter::evaluate(
['column' => 'Amount', 'operator' => 'gte', 'value' => '100'],
['Amount' => '99.99']
));
}
public function testLtOperator(): void
{
$this->assertTrue(RowFilter::evaluate(
['column' => 'Amount', 'operator' => 'lt', 'value' => '100'],
['Amount' => '50']
));
$this->assertFalse(RowFilter::evaluate(
['column' => 'Amount', 'operator' => 'lt', 'value' => '100'],
['Amount' => '200']
));
}
public function testLteOperator(): void
{
$this->assertTrue(RowFilter::evaluate(
['column' => 'Amount', 'operator' => 'lte', 'value' => '100'],
['Amount' => '100']
));
$this->assertFalse(RowFilter::evaluate(
['column' => 'Amount', 'operator' => 'lte', 'value' => '100'],
['Amount' => '100.01']
));
}
// -------------------------------------------------------------------------
// Groups
// -------------------------------------------------------------------------
public function testAndGroupBothTrue(): void
{
$this->assertTrue(RowFilter::evaluate([
'and' => [
['column' => 'A', 'operator' => 'empty'],
['column' => 'B', 'operator' => 'empty'],
],
], ['A' => '', 'B' => '']));
}
public function testAndGroupOneFalse(): void
{
$this->assertFalse(RowFilter::evaluate([
'and' => [
['column' => 'A', 'operator' => 'empty'],
['column' => 'B', 'operator' => 'empty'],
],
], ['A' => '', 'B' => 'not-empty']));
}
public function testOrGroupOneTrue(): void
{
$this->assertTrue(RowFilter::evaluate([
'or' => [
['column' => 'A', 'operator' => 'equals', 'value' => 'yes'],
['column' => 'B', 'operator' => 'equals', 'value' => 'yes'],
],
], ['A' => 'no', 'B' => 'yes']));
}
public function testOrGroupBothFalse(): void
{
$this->assertFalse(RowFilter::evaluate([
'or' => [
['column' => 'A', 'operator' => 'equals', 'value' => 'yes'],
['column' => 'B', 'operator' => 'equals', 'value' => 'yes'],
],
], ['A' => 'no', 'B' => 'no']));
}
// -------------------------------------------------------------------------
// Nested groups
// -------------------------------------------------------------------------
public function testNestedAndOrGroup(): void
{
// (A is empty) AND (B equals "foo" OR C not-empty)
$node = [
'and' => [
['column' => 'A', 'operator' => 'empty'],
[
'or' => [
['column' => 'B', 'operator' => 'equals', 'value' => 'foo'],
['column' => 'C', 'operator' => 'not-empty'],
],
],
],
];
// A empty, B matches → true
$this->assertTrue(RowFilter::evaluate($node, ['A' => '', 'B' => 'foo', 'C' => '']));
// A empty, C not-empty → true
$this->assertTrue(RowFilter::evaluate($node, ['A' => '', 'B' => 'bar', 'C' => 'value']));
// A empty, but neither B nor C match → false
$this->assertFalse(RowFilter::evaluate($node, ['A' => '', 'B' => 'bar', 'C' => '']));
// A not empty → false
$this->assertFalse(RowFilter::evaluate($node, ['A' => 'x', 'B' => 'foo', 'C' => '']));
}
// -------------------------------------------------------------------------
// Unknown operator
// -------------------------------------------------------------------------
public function testUnknownOperatorThrows(): void
{
$this->expectException(\InvalidArgumentException::class);
RowFilter::evaluate(
['column' => 'A', 'operator' => 'nonexistent'],
['A' => 'value']
);
}
// -------------------------------------------------------------------------
// Missing column (treats as empty string)
// -------------------------------------------------------------------------
public function testMissingColumnTreatedAsEmpty(): void
{
$this->assertTrue(RowFilter::evaluate(
['column' => 'NonExistent', 'operator' => 'empty'],
['A' => 'something']
));
}
}
+14 -17
View File
@@ -1,17 +1,14 @@
Belastung,Gutschrift,date,process_date,opposing_name,tags,description,opposing_account,notes,account_iban,account_currency
-600.00,,2022-12-30,2022-12-30,"David Peter Reindl",Dauerauftrag,"Steuerrueckstellung
David Peter Reindl;8906 Bonstetten","CH37 0026 7267 9314 35M2 P","9967864LK2659211
8906 Bonstetten","CH18 0026 7267 9314 3540 D",CHF
-46.35,,2022-12-30,2022-12-31,"UBS AG",,"Periode: 2022-10-01 - 2022-12-30
Zinsabschluss",,9900365AP6356307,"CH18 0026 7267 9314 3540 D",CHF
-39.90,,2022-12-30,2022-12-30,"Swisscom Grossunternehme",TWINT,"Swisscom Grossunternehme; Zahlung UBS TWINT",,"9967364GK5707142
8004 Zuerich","CH18 0026 7267 9314 3540 D",CHF
-8.75,,2022-12-28,2022-12-27,"Coop Pronto Chur",Debitkarte,"18279748-0 08/24
Coop Pronto Chur;7007 Chur",,"9930862BN7826808
7007 Chur","CH18 0026 7267 9314 3540 D",CHF
-1800.00,,2022-12-27,2022-12-27,"Janine Geigele",e-banking,"Skiferien Dolomiten
Janine Geigele;Am Wasser 36; 8049 Zuerich; CH","CH63 0023 2232 5560 5988 0","9967361TI3188436
8049 Zuerich","CH18 0026 7267 9314 3540 D",CHF
,9.00,2022-12-22,2022-12-22,"Friis, Daniela Silvia",TWINT,"Friis, Daniela Silvia",,9930356GK0440989,"CH18 0026 7267 9314 3540 D",CHF
,19764.80,2022-11-25,2022-11-25,SBB,Gutschrift,"SBB;Corporate Treasury",,9901820E67741531,"CH18 0026 7267 9314 3540 D",CHF
-14.00,,2022-08-22,2022-08-21,"Friis-Loop, Daniela",TWINT,"Friis-Loop, Daniela; Belastung UBS TWINT",,9967233GK1553933,"CH18 0026 7267 9314 3540 D",CHF
Belastung,Gutschrift,date,process_date,tags,opposing_iban,opposing_account,opposing_name,notes,description,account_iban,account_currency
-600.00,,2022-12-30,2022-12-30,Dauerauftrag,"CH37 0026 7267 9314 35M2 P",,"David Peter Reindl","8906 Bonstetten
9967864LK2659211","David Peter Reindl;8906 Bonstetten; STEUERRUECKSTELLUNG; Dauerauftrag","CH18 0026 7267 9314 3540 D",CHF
-46.35,,2022-12-30,2022-12-31,,,,"UBS AG",9900365AP6356307,"Saldo Zinsabschluss; Periode: 2022-10-01 - 2022-12-30","CH18 0026 7267 9314 3540 D",CHF
-39.90,,2022-12-30,2022-12-30,TWINT,,,"Swisscom Grossunternehme","Muellerstrasse 16 8004 Zuerich TWINT-Acc.:+41796305690
9967364GK5707142","SWISSCOM GROSSUNTERNEHME; Zahlung UBS TWINT; Muellerstrasse 16 na, 8004 Zuerich TWINT-Acc.:+41796305690","CH18 0026 7267 9314 3540 D",CHF
-8.75,,2022-12-28,2022-12-27,"Abend Debitkarte",,,"Coop Pronto Chur","18279748-0 08/24
7007 Chur
9930862BN7826808","Coop Pronto Chur;7007 Chur; Zahlung Debitkarte","CH18 0026 7267 9314 3540 D",CHF
-1800.00,,2022-12-27,2022-12-27,e-banking,"CH63 0023 2232 5560 5988 0",,"Janine Geigele","8049 Zuerich
9967361TI3188436","Janine Geigele;Am Wasser 36; 8049 Zuerich; CH; SKIFERIEN DOLOMITEN; e-banking-Vergütungsauftrag; Wohnung Dolomiten, 2 Personen","CH18 0026 7267 9314 3540 D",CHF
,9.00,2022-12-22,2022-12-22,TWINT,,,"Friis, Daniela Silvia",9930356GK0440989,"Friis, Daniela Silvia; Gutschrift UBS TWINT; +41796741245; TWINT-Acc.:+41796305690","CH18 0026 7267 9314 3540 D",CHF
,19764.80,2022-11-25,2022-11-25,Gutschrift,,,SBB,9901820E67741531,"SBB;Corporate Treasury; Gutschrift; Lohn/Gehalt 00229537/202211","CH18 0026 7267 9314 3540 D",CHF
-14.00,,2022-08-22,2022-08-21,TWINT,,,"Friis-Loop, Daniela",9967233GK1553933,"FRIIS-LOOP, DANIELA; Belastung UBS TWINT; +41796741245; TWINT-Acc.:+41796305690","CH18 0026 7267 9314 3540 D",CHF
1 Belastung Gutschrift date process_date tags opposing_iban opposing_account opposing_name notes description account_iban account_currency
2 -600.00 2022-12-30 2022-12-30 Dauerauftrag CH37 0026 7267 9314 35M2 P CH37 0026 7267 9314 35M2 P David Peter Reindl 9967864LK2659211 8906 Bonstetten 8906 Bonstetten 9967864LK2659211 Steuerrueckstellung David Peter Reindl;8906 Bonstetten David Peter Reindl;8906 Bonstetten; STEUERRUECKSTELLUNG; Dauerauftrag CH18 0026 7267 9314 3540 D CHF
3 -46.35 2022-12-30 2022-12-31 UBS AG 9900365AP6356307 Periode: 2022-10-01 - 2022-12-30 Zinsabschluss Saldo Zinsabschluss; Periode: 2022-10-01 - 2022-12-30 CH18 0026 7267 9314 3540 D CHF
4 -39.90 2022-12-30 2022-12-30 TWINT Swisscom Grossunternehme 9967364GK5707142 8004 Zuerich Muellerstrasse 16 8004 Zuerich TWINT-Acc.:+41796305690 9967364GK5707142 Swisscom Grossunternehme; Zahlung UBS TWINT SWISSCOM GROSSUNTERNEHME; Zahlung UBS TWINT; Muellerstrasse 16 na, 8004 Zuerich TWINT-Acc.:+41796305690 CH18 0026 7267 9314 3540 D CHF
5 -8.75 2022-12-28 2022-12-27 Debitkarte Abend Debitkarte Coop Pronto Chur 9930862BN7826808 7007 Chur 18279748-0 08/24 7007 Chur 9930862BN7826808 18279748-0 08/24 Coop Pronto Chur;7007 Chur Coop Pronto Chur;7007 Chur; Zahlung Debitkarte CH18 0026 7267 9314 3540 D CHF
6 -1800.00 2022-12-27 2022-12-27 e-banking CH63 0023 2232 5560 5988 0 CH63 0023 2232 5560 5988 0 Janine Geigele 9967361TI3188436 8049 Zuerich 8049 Zuerich 9967361TI3188436 Skiferien Dolomiten Janine Geigele;Am Wasser 36; 8049 Zuerich; CH Janine Geigele;Am Wasser 36; 8049 Zuerich; CH; SKIFERIEN DOLOMITEN; e-banking-Vergütungsauftrag; Wohnung Dolomiten, 2 Personen CH18 0026 7267 9314 3540 D CHF
7 9.00 2022-12-22 2022-12-22 TWINT Friis, Daniela Silvia 9930356GK0440989 Friis, Daniela Silvia Friis, Daniela Silvia; Gutschrift UBS TWINT; +41796741245; TWINT-Acc.:+41796305690 CH18 0026 7267 9314 3540 D CHF
8 19764.80 2022-11-25 2022-11-25 Gutschrift SBB 9901820E67741531 SBB;Corporate Treasury SBB;Corporate Treasury; Gutschrift; Lohn/Gehalt 00229537/202211 CH18 0026 7267 9314 3540 D CHF
9 -14.00 2022-08-22 2022-08-21 TWINT Friis-Loop, Daniela 9967233GK1553933 Friis-Loop, Daniela; Belastung UBS TWINT FRIIS-LOOP, DANIELA; Belastung UBS TWINT; +41796741245; TWINT-Acc.:+41796305690 CH18 0026 7267 9314 3540 D CHF
10
11
12
13
14