From f651573d6bc445f3a0a17feb97d0e986b675ec29 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Kucha=C5=99?= <honza.kuchar@grifart.cz>
Date: Fri, 6 May 2016 23:51:03 +0200
Subject: [PATCH] Introducing TrackingCursor which knows it's current position

---
 .../{FastCursor.php => Cursor.php}            |  28 +-
 src/PostgresDriver/CursorException.php        |  29 +++
 src/PostgresDriver/ICursor.php                |   8 +-
 src/PostgresDriver/TrackedCursor.php          | 242 ++++++++++++++++++
 .../PostgresDriver/CursorInterfaceTest.php    |  39 +--
 tests/Store/PostgresDriver/CursorTest.phpt    |  76 ++++++
 .../Store/PostgresDriver/FastCursorTest.phpt  |  33 ---
 .../PostgresDriver/TrackedCursorTest.phpt     | 159 ++++++++++++
 8 files changed, 528 insertions(+), 86 deletions(-)
 rename src/PostgresDriver/{FastCursor.php => Cursor.php} (89%)
 create mode 100644 src/PostgresDriver/TrackedCursor.php
 create mode 100644 tests/Store/PostgresDriver/CursorTest.phpt
 delete mode 100644 tests/Store/PostgresDriver/FastCursorTest.phpt
 create mode 100644 tests/Store/PostgresDriver/TrackedCursorTest.phpt

diff --git a/src/PostgresDriver/FastCursor.php b/src/PostgresDriver/Cursor.php
similarity index 89%
rename from src/PostgresDriver/FastCursor.php
rename to src/PostgresDriver/Cursor.php
index 808309f..cc11e09 100644
--- a/src/PostgresDriver/FastCursor.php
+++ b/src/PostgresDriver/Cursor.php
@@ -7,7 +7,7 @@ namespace Grifart\Mappi\Store\PostgresDriver;
 
 use Dibi\Connection;
 
-class FastCursor implements ICursor
+class Cursor implements ICursor
 {
 
 	/** @var Connection */
@@ -40,7 +40,7 @@ class FastCursor implements ICursor
 		return $this->connection;
 	}
 
-	public function getCursorName() : string
+	public function getName() : string
 	{
 		return $this->cursorName;
 	}
@@ -55,7 +55,7 @@ class FastCursor implements ICursor
 		$result = $this->getConnection()->query(
 			"MOVE ABSOLUTE %i IN %n",
 			$index,
-			$this->getCursorName()
+			$this->getName()
 		);
 		if($result !== 1) {
 			throw CursorException::cursorOverflow();
@@ -67,7 +67,7 @@ class FastCursor implements ICursor
 		$result = $this->getConnection()->query(
 			"MOVE RELATIVE %i IN %n",
 			$rows,
-			$this->getCursorName()
+			$this->getName()
 		);
 		if($result !== 1) {
 			throw CursorException::cursorOverflow();
@@ -78,7 +78,7 @@ class FastCursor implements ICursor
 	{
 		$this->getConnection()->query(
 			"MOVE ABSOLUTE 0 IN %n;",
-			$this->getCursorName()
+			$this->getName()
 		);
 	}
 
@@ -86,7 +86,7 @@ class FastCursor implements ICursor
 	{
 		$result = $this->getConnection()->query(
 			"MOVE FIRST IN %n;", // == ABSOLUTE 1
-			$this->getCursorName()
+			$this->getName()
 		);
 		if($result !== 1) {
 			throw CursorException::cursorOverflow();
@@ -97,7 +97,7 @@ class FastCursor implements ICursor
 	{
 		$result = $this->getConnection()->query(
 			"MOVE LAST IN %n", // == ABSOLUTE -1
-			$this->getCursorName()
+			$this->getName()
 		);
 		if($result !== 1) {
 			throw CursorException::cursorOverflow();
@@ -108,8 +108,8 @@ class FastCursor implements ICursor
 	{
 		$this->getConnection()->query(
 			"MOVE ABSOLUTE -1 IN %n; MOVE NEXT IN %n;",
-			$this->getCursorName(),
-			$this->getCursorName()
+			$this->getName(),
+			$this->getName()
 		);
 	}
 
@@ -122,7 +122,7 @@ class FastCursor implements ICursor
 		return $this->connection->query(
 			$forward ? "FETCH FORWARD %i FROM %n" : "FETCH BACKWARD %i FROM %n",
 			$rows,
-			$this->getCursorName()
+			$this->getName()
 		)->fetchAll();
 
 	}
@@ -169,7 +169,7 @@ class FastCursor implements ICursor
 		return $this->connection->query(
 			"FETCH ABSOLUTE %i FROM %n",
 			$index,
-			$this->getCursorName()
+			$this->getName()
 		)->fetch();
 	}
 
@@ -178,7 +178,7 @@ class FastCursor implements ICursor
 		return $this->connection->query(
 			"FETCH RELATIVE %i FROM %n",
 			$rows,
-			$this->getCursorName()
+			$this->getName()
 		)->fetch();
 	}
 
@@ -186,7 +186,7 @@ class FastCursor implements ICursor
 	{
 		return $this->connection->query(
 			"FETCH FORWARD ALL FROM %n",
-			$this->getCursorName()
+			$this->getName()
 		)->fetchAll();
 	}
 
@@ -194,7 +194,7 @@ class FastCursor implements ICursor
 	{
 		return $this->connection->query(
 			"FETCH BACKWARD ALL FROM %n",
-			$this->getCursorName()
+			$this->getName()
 		)->fetchAll();
 	}
 
diff --git a/src/PostgresDriver/CursorException.php b/src/PostgresDriver/CursorException.php
index b5fa8e4..e5d24d5 100644
--- a/src/PostgresDriver/CursorException.php
+++ b/src/PostgresDriver/CursorException.php
@@ -11,6 +11,13 @@ class CursorException extends \LogicException
 {
 	const MESSAGE_NO_DATA_TO_FETCH = "There was not data to fetch. Haven't you reached end of cursor?";
 	const MESSAGE_OVERFLOW = "Cursor overflow. You've hit end or beginning of the cursor.";
+	const MESSAGE_NO_DATA_CURSOR_RESET = "No data received. Cursor has been reset to zero.";
+	const MESSAGE_CANNOT_RECOVER_TO_ORIGINAL_POSITION = "Cursor is in undefined state. " .
+		"Error occurred and system tried to reset cursor into state before operation. " .
+		"Unfortunately it failed. Original exception attached.";
+	const MESSAGE_UNTRACEABLE_VALUE = "Given value is not traceable. %s";
+	const MESSAGE_CANNOT_REACH_THE_END = "Cannot move cursor to end. Cursor was " .
+        "in undirected state. Cursor has been left in unknown state.";
 
 	/**
 	 * @inheritDoc
@@ -30,4 +37,26 @@ class CursorException extends \LogicException
 		return new static(self::MESSAGE_OVERFLOW);
 	}
 
+	public static function noDataCursorHasBeenSetToZero()
+	{
+		return new static(self::MESSAGE_NO_DATA_CURSOR_RESET);
+	}
+
+	public static function cannotRecoverCursorIntoOriginalPosition(\Throwable $previous)
+	{
+		return new static(self::MESSAGE_CANNOT_RECOVER_TO_ORIGINAL_POSITION, 0, $previous);
+	}
+
+	public static function cannotMoveToTheEnd()
+	{
+		return new static(self::MESSAGE_CANNOT_REACH_THE_END);
+	}
+
+	public static function untraceableValue($moreInfo)
+	{
+		return new static(
+			sprintf(self::MESSAGE_UNTRACEABLE_VALUE, $moreInfo)
+		);
+	}
+
 }
\ No newline at end of file
diff --git a/src/PostgresDriver/ICursor.php b/src/PostgresDriver/ICursor.php
index 1fe4f8e..3f3d31e 100644
--- a/src/PostgresDriver/ICursor.php
+++ b/src/PostgresDriver/ICursor.php
@@ -3,6 +3,7 @@
  * This file is part of mappi/store.
  */
 namespace Grifart\Mappi\Store\PostgresDriver;
+use Dibi\Connection;
 
 /**
  * Represents PostgreSQL cursor
@@ -13,7 +14,12 @@ namespace Grifart\Mappi\Store\PostgresDriver;
  */
 interface ICursor
 {
-	public function getCursorName() : string;
+	public function getName() : string;
+
+	/**
+	 * @internal
+	 */
+	public function getConnection() : Connection;
 
 	/**
 	 * @param int $rows
diff --git a/src/PostgresDriver/TrackedCursor.php b/src/PostgresDriver/TrackedCursor.php
new file mode 100644
index 0000000..e8d479e
--- /dev/null
+++ b/src/PostgresDriver/TrackedCursor.php
@@ -0,0 +1,242 @@
+<?php declare(strict_types = 1);
+/**
+ * This file is part of mappi/store.
+ */
+
+namespace Grifart\Mappi\Store\PostgresDriver;
+
+use Dibi\Connection;
+use Dibi\NotImplementedException;
+use MongoDB\Driver\Cursor;
+
+class TrackedCursor implements ICursor
+{
+
+	/** @var int */
+	private $total;
+
+	/**
+	 * null-based index of current cursor state
+	 * @var int
+	 */
+	private $position = 0;
+
+	
+	/** @var ICursor */
+	private $cursor;
+
+	/**
+	 * @param ICursor $cursor Cursor in initial state (index=0)
+	 * Tip: if you are not sure that cursor will be in initial state, call ->moveToBeginning() after initialization.
+	 */
+	public function __construct(ICursor $cursor)
+	{
+		$this->cursor = $cursor;
+	}
+
+	/**
+	 * @return int
+	 */
+	public function getPosition()
+	{
+		return $this->position;
+	}
+
+	public function getConnection() : Connection
+	{
+		return $this->cursor->getConnection();
+	}
+
+	public function getName() : string
+	{
+		return $this->cursor->getName();
+	}
+
+	public function moveTo(int $index)
+	{
+		if($index < 0) {
+			throw CursorException::untraceableValue("Given index is bellow zero. Use moveToEnd() + moveBy() combination.");
+		}
+		if($this->handleSpecialCases($index)) { // this was special case and was handled by special handling bellow
+			return;
+		}
+		$this->cursor->moveTo($index);
+		$this->position = $index;
+	}
+
+	public function moveBy(int $rows)
+	{
+		$originalPosition = $this->getPosition();
+		$index = $originalPosition + $rows;
+		//$this->moveTo($index); // todo: is this efficient?
+
+		if($this->handleSpecialCases($originalPosition + $rows)) { // this was special case and was handled by special handling bellow
+			return;
+		}
+		try {
+			$this->cursor->moveBy($rows);
+			$this->position += $rows;
+		} catch (CursorException $e) {
+			$this->tryToRecoverFromError(
+				CursorException::cursorOverflow(),
+				$originalPosition
+			);
+		}
+	}
+
+	public function moveToBeginning()
+	{
+		$this->cursor->moveToBeginning();
+		$this->position = 0;
+	}
+
+	public function moveToFirst()
+	{
+		$this->cursor->moveToFirst();
+		$this->position = 1;
+	}
+
+	public function moveToLast()
+	{
+		$this->moveToEnd();
+		$this->moveBy(-1);
+	}
+
+	public function moveToEnd()
+	{
+		$rowsSkipped = $this->getConnection()->query(
+			"MOVE FORWARD ALL IN %n",
+			$this->getName()
+		);
+		if ($rowsSkipped === 0) {
+			// edge case: when already in END or LAST position
+			if ($this->total !== NULL)
+			{
+				$indexOfEnd = $this->total + 1;
+				$indexOfLast = $this->total;
+				if (
+					$this->position === $indexOfLast ||
+					$this->position === $indexOfEnd
+				)
+				{
+					$this->position = $indexOfEnd;
+					return;
+				}
+
+			} else {
+				throw CursorException::cannotMoveToTheEnd();
+			}
+		}
+		$this->position += $rowsSkipped + 1;
+		$this->total = $this->position - 1; // end is one step after last item
+	}
+
+	public function fetch(int $rows)
+	{
+		$forward = $rows > 0;
+		$rows = $this->cursor->fetch($rows);
+		$this->position += count($rows) * ($forward ? 1 : -1);
+		return $rows;
+	}
+
+	public function fetchNext()
+	{
+		$row = $this->cursor->fetchNext();
+		if($row !== FALSE) {
+			$this->position += 1;
+		}
+		return $row;
+	}
+
+	public function fetchCurrent()
+	{
+		return $this->cursor->fetchCurrent();
+	}
+
+	public function fetchNextSingle()
+	{
+		$value = $this->cursor->fetchNextSingle();
+		$this->position += 1;
+		return $value;
+	}
+
+	public function fetchCurrentSingle()
+	{
+		return $this->cursor->fetchCurrentSingle();
+	}
+
+	public function fetchOneAt(int $index)
+	{
+		$originalPosition = $this->getPosition();
+		$row = $this->cursor->fetchOneAt($index);
+		if($row !== FALSE) {
+			$this->position = $index;
+		} else {
+			$this->tryToRecoverFromError(
+				CursorException::noDataCursorHasBeenSetToZero(),
+				$originalPosition
+			);
+		}
+		return $row;
+	}
+
+	public function fetchOneBy(int $rows)
+	{
+		$originalPosition = $this->getPosition();
+		$row = $this->cursor->fetchOneBy($rows);
+		if($row !== FALSE) {
+			$this->position += $rows;
+		} else {
+			$this->tryToRecoverFromError(
+				CursorException::noDataCursorHasBeenSetToZero(),
+				$originalPosition
+			);
+		}
+		return $row;
+	}
+
+	public function fetchRemaining()
+	{
+		$data = $this->cursor->fetchRemaining();
+		$this->position += count($data);
+		return $data;
+	}
+
+	public function fetchForegoing()
+	{
+		$data = $this->cursor->fetchForegoing();
+		$this->position -= count($data);
+		return $data;
+	}
+
+	private function handleSpecialCases($index) : bool
+	{
+		// if requested position was BEGINNING
+		if($index === 0) {
+			$this->moveToBeginning();
+			return TRUE;
+		}
+
+		// if requested position was the END
+		if($this->total !== NULL) {
+			if($this->total + 1 === $index) {
+				$this->moveToEnd();
+				return TRUE;
+			}
+		}
+
+		// wasn't a special case
+		return FALSE;
+	}
+
+	private function tryToRecoverFromError(\Throwable $throwable, $moveTo)
+	{
+		try {
+			$this->moveTo($moveTo);
+		} catch (\Throwable $e) {
+			throw CursorException::cannotRecoverCursorIntoOriginalPosition($throwable);
+		}
+		
+		throw $throwable;
+	}
+}
\ No newline at end of file
diff --git a/tests/Store/PostgresDriver/CursorInterfaceTest.php b/tests/Store/PostgresDriver/CursorInterfaceTest.php
index 7aa7872..7cdfb2f 100644
--- a/tests/Store/PostgresDriver/CursorInterfaceTest.php
+++ b/tests/Store/PostgresDriver/CursorInterfaceTest.php
@@ -236,37 +236,6 @@ abstract class CursorInterfaceTest extends BaseTest
 		}, CursorException::class, CursorException::MESSAGE_OVERFLOW);
 	}
 
-	public function test_givenFirstPosition_whenMoveToLeft_thenGetError()
-	{
-		$this->uut->moveToFirst();
-
-		Assert::exception(function() {
-			$this->uut->moveBy(-1);
-		}, CursorException::class, CursorException::MESSAGE_OVERFLOW);
-
-		// todo: unfortunately moveBy moved cursor into index=0 even when exception occured
-		// todo: this should be supported or should not modify state
-
-		Assert::exception(function() {
-			$this->uut->fetchCurrentSingle();
-		}, CursorException::class, CursorException::MESSAGE_NO_DATA_TO_FETCH);
-	}
-	
-	public function test_givenLastPosition_whenMoveToRight_thenGetError()
-	{
-		$this->uut->moveToLast();
-
-		Assert::exception(function() {
-			$this->uut->moveBy(1);
-		}, CursorException::class, CursorException::MESSAGE_OVERFLOW);
-
-		// todo: unfortunately moveBy moved cursor into index=0 even when exception occured
-		// todo: this should be supported or should not modify state
-
-		Assert::exception(function() {
-			$this->uut->fetchCurrentSingle();
-		}, CursorException::class, CursorException::MESSAGE_NO_DATA_TO_FETCH);
-	}
 
 	public function test_giveSomePosition_whenMoveToAfterEnd_thenGetError()
 	{
@@ -276,12 +245,6 @@ abstract class CursorInterfaceTest extends BaseTest
 		}, CursorException::class, CursorException::MESSAGE_OVERFLOW);
 	}
 
-	public function test_giveSomePosition_whenMoveToBeforeBeginning_thenGetError()
-	{
-		$this->uut->moveTo(1);
-		Assert::exception(function() {
-			$this->uut->moveTo(0);
-		}, CursorException::class, CursorException::MESSAGE_OVERFLOW);
-	}
+	
 
 }
diff --git a/tests/Store/PostgresDriver/CursorTest.phpt b/tests/Store/PostgresDriver/CursorTest.phpt
new file mode 100644
index 0000000..beef539
--- /dev/null
+++ b/tests/Store/PostgresDriver/CursorTest.phpt
@@ -0,0 +1,76 @@
+<?php
+/**
+ * @testCase
+ */
+
+namespace Grifart\Mappi\Tests\Store\Store\PostgresDriver;
+
+use Grifart\Mappi\Store\PostgresDriver\CursorException;
+use Grifart\Mappi\Store\PostgresDriver\Cursor;
+use Tester\Assert;
+
+require_once __DIR__ . "/../../bootstrap.php";
+require_once __DIR__ . "/CursorInterfaceTest.php";
+
+class CursorTest extends CursorInterfaceTest
+{
+	protected function setUp()
+	{
+		global $connection, $SQL_thousandRowsAscending;
+		$connection->begin();
+
+		$this->uut = new Cursor($connection, $SQL_thousandRowsAscending, true);
+		parent::setUp();
+	}
+
+	public function tearDown()
+	{
+		global $connection;
+		$connection->rollback();
+
+		parent::tearDown();
+	}
+
+	public function test_givenFirstPosition_whenMoveToLeft_thenGetError()
+	{
+		$this->uut->moveToFirst();
+
+		Assert::exception(function() {
+			$this->uut->moveBy(-1);
+		}, CursorException::class, CursorException::MESSAGE_OVERFLOW);
+
+		// todo: unfortunately moveBy moved cursor into index=0 even when exception occured
+		// todo: this should be supported or should not modify state
+
+		Assert::exception(function() {
+			$this->uut->fetchCurrentSingle();
+		}, CursorException::class, CursorException::MESSAGE_NO_DATA_TO_FETCH);
+	}
+
+	public function test_givenLastPosition_whenMoveToRight_thenGetError()
+	{
+		$this->uut->moveToLast();
+
+		Assert::exception(function() {
+			$this->uut->moveBy(1);
+		}, CursorException::class, CursorException::MESSAGE_OVERFLOW);
+
+		// todo: unfortunately moveBy moved cursor into index=0 even when exception occured
+		// todo: this should be supported or should not modify state
+
+		Assert::exception(function() {
+			$this->uut->fetchCurrentSingle();
+		}, CursorException::class, CursorException::MESSAGE_NO_DATA_TO_FETCH);
+	}
+
+	public function test_giveSomePosition_whenMoveToBeginning_thenGetError()
+	{
+		$this->uut->moveTo(1);
+		// todo: fixme?
+		Assert::exception(function() {
+			$this->uut->moveTo(0);
+		}, CursorException::class);
+	}
+}
+
+(new CursorTest())->run();
diff --git a/tests/Store/PostgresDriver/FastCursorTest.phpt b/tests/Store/PostgresDriver/FastCursorTest.phpt
deleted file mode 100644
index ea8a9b6..0000000
--- a/tests/Store/PostgresDriver/FastCursorTest.phpt
+++ /dev/null
@@ -1,33 +0,0 @@
-<?php
-/**
- * @testCase
- */
-
-namespace Grifart\Mappi\Tests\Store\Store\PostgresDriver;
-
-use Grifart\Mappi\Store\PostgresDriver\FastCursor;
-
-require_once __DIR__ . "/../../bootstrap.php";
-require_once __DIR__ . "/CursorInterfaceTest.php";
-
-class FastCursorTest extends CursorInterfaceTest
-{
-	protected function setUp()
-	{
-		global $connection, $SQL_thousandRowsAscending;
-		$connection->begin();
-
-		$this->uut = new FastCursor($connection, $SQL_thousandRowsAscending, true);
-		parent::setUp();
-	}
-
-	public function tearDown()
-	{
-		global $connection;
-		$connection->rollback();
-
-		parent::tearDown();
-	}
-}
-
-(new FastCursorTest())->run();
diff --git a/tests/Store/PostgresDriver/TrackedCursorTest.phpt b/tests/Store/PostgresDriver/TrackedCursorTest.phpt
new file mode 100644
index 0000000..7da5bf8
--- /dev/null
+++ b/tests/Store/PostgresDriver/TrackedCursorTest.phpt
@@ -0,0 +1,159 @@
+<?php
+/**
+ * @testCase
+ */
+
+namespace Grifart\Mappi\Tests\Store\Store\PostgresDriver;
+
+use Grifart\Mappi\Store\PostgresDriver\CursorException;
+use Grifart\Mappi\Store\PostgresDriver\Cursor;
+use Grifart\Mappi\Store\PostgresDriver\TrackedCursor;
+use Tester\Assert;
+
+require_once __DIR__ . "/../../bootstrap.php";
+require_once __DIR__ . "/CursorInterfaceTest.php";
+
+class TrackedCursorTest extends CursorInterfaceTest
+{
+	/** @link https://en.wikipedia.org/wiki/42_(number)#Hitchhiker.27s_Guide_to_the_Galaxy */
+	const THE_MAGIC_NUMBER = 42;
+
+	/** @var TrackedCursor */
+	protected $uut;
+
+	protected function setUp()
+	{
+		global $connection, $SQL_thousandRowsAscending;
+		$connection->begin();
+
+		$fastCursor = new Cursor($connection, $SQL_thousandRowsAscending, true);
+		$this->uut = new TrackedCursor($fastCursor);
+		parent::setUp();
+	}
+
+	public function tearDown()
+	{
+		global $connection;
+		$connection->rollback();
+
+		parent::tearDown();
+	}
+
+	public function test_givenBeginningPosition_whenMoveLeft_thenGetError()
+	{
+		$this->uut->moveToBeginning();
+
+		Assert::exception(function() {
+			// todo: what to do with this non-uniformity with original Cursor?
+			$this->uut->moveBy(-1);
+		}, CursorException::class, CursorException::MESSAGE_OVERFLOW);
+
+		$this->uut->moveBy(1);
+		Assert::equal(1, $this->uut->fetchCurrentSingle());
+	}
+
+	public function test_givenEndPosition_whenMoveToRight_thenGetError()
+	{
+		$this->uut->moveToEnd();
+
+		Assert::exception(function() {
+			// todo: what to do with this non-uniformity with original Cursor?
+			$this->uut->moveBy(1);
+		}, CursorException::class, CursorException::MESSAGE_OVERFLOW);
+
+		$this->uut->moveBy(-1);
+		Assert::equal(1000, $this->uut->fetchCurrentSingle());
+	}
+
+	// corrected behaviour of original cursor
+	public function test_givenFirstPosition_whenMoveToLeft_thenImOnFirstValue()
+	{
+		$this->uut->moveToFirst();
+
+		$this->uut->moveBy(-1);
+		// todo: what to do with this non-uniformity with original Cursor?
+		Assert::equal(1, $this->uut->fetchNextSingle());
+	}
+
+	public function test_givenLastPosition_whenMoveToRight_thenGetError()
+	{
+		// todo: what to do with this non-uniformity with original Cursor?
+		$this->uut->moveToLast();
+		$this->uut->moveBy(1); // here is ok, now in original cursor
+
+		$this->uut->moveBy(-1); // todo: make decorator which will make this nicer
+		Assert::equal(1000, $this->uut->fetchCurrentSingle());
+	}
+
+
+
+	// ->getPosition() tests:
+
+	// moveToBeginning()
+	public function test_givenInitialPosition_whenGetPosition_thenGetZero()
+	{
+		Assert::equal(0, $this->uut->getPosition());
+
+		$this->uut->moveToBeginning();
+		Assert::equal(0, $this->uut->getPosition());
+
+		$this->uut->moveToFirst();
+		Assert::equal(1, $this->uut->getPosition());
+	}
+
+	public function test_givenMiddlePosition_whenMoveAround_thenGetCorrectPosition()
+	{
+		$this->uut->moveTo(self::THE_MAGIC_NUMBER);
+		$this->uut->moveTo(self::THE_MAGIC_NUMBER + self::THE_MAGIC_NUMBER);
+		$this->uut->moveBy(-self::THE_MAGIC_NUMBER);
+		$this->uut->moveBy(+self::THE_MAGIC_NUMBER);
+		$this->uut->moveBy(-self::THE_MAGIC_NUMBER);
+		Assert::equal(self::THE_MAGIC_NUMBER, $this->uut->getPosition());
+	}
+
+	public function test_givenEndPosition_whenGetPosition_thenGetTotalPlusOne()
+	{
+		$this->uut->moveToEnd();
+		Assert::equal(1001, $this->uut->getPosition());
+	}
+
+	public function test_givenInitialPosition_whenMoveToLast_thenGetTotal()
+	{
+		$this->uut->moveToLast();
+		Assert::equal(1000, $this->uut->getPosition());
+	}
+
+	// nasty edge cases:
+	public function test_givenLastPosition_whenMoveToEnd_thenGetEndPosition()
+	{
+		$this->uut->moveToLast();
+		$this->uut->moveToEnd();
+		Assert::equal(1001, $this->uut->getPosition());
+
+		// regression test:
+		$this->uut->moveToEnd();
+		Assert::equal(1001, $this->uut->getPosition());
+	}
+
+	public function test_givenEndPosition_whenMoveToLast_thenGetLastPosition()
+	{
+		$this->uut->moveToEnd();
+		$this->uut->moveToLast();
+		Assert::equal(1000, $this->uut->getPosition());
+
+		// regression test:
+		$this->uut->moveToLast();
+		Assert::equal(1000, $this->uut->getPosition());
+	}
+
+	public function test_giveSomePosition_whenMoveToBeginning_thenGetToBeginning()
+	{
+		$this->uut->moveTo(42);
+		$this->uut->moveTo(0);
+
+		Assert::equal(0, $this->uut->getPosition());
+		Assert::equal(1, $this->uut->fetchNextSingle());
+	}
+}
+
+(new TrackedCursorTest())->run();
\ No newline at end of file
-- 
GitLab