diff --git a/.gitignore b/.gitignore index 55d9dd391..957307d54 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ cypress/videos cypress/snapshots cypress/downloads -js/*hot-update.* \ No newline at end of file +js/*hot-update.* +tests/.phpunit.result.cache diff --git a/appinfo/info.xml b/appinfo/info.xml index 2e73f5125..1141c153b 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -9,7 +9,7 @@ Photos Your memories under your control Your memories under your control - 3.0.1 + 3.0.2 agpl John Molakvoæ Photos @@ -22,7 +22,6 @@ https://github.com/nextcloud/photos https://github.com/nextcloud/photos/issues https://github.com/nextcloud/photos.git - diff --git a/composer.json b/composer.json index cf8cad5b4..fa8ab37f6 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "psalm:update-baseline": "psalm.phar --threads=1 --update-baseline", "psalm:clear": "psalm.phar --clear-cache && psalm --clear-global-cache", "psalm:fix": "psalm.phar --alter --issues=InvalidReturnType,InvalidNullableReturnType,MissingParamType,InvalidFalsableReturnType", - "test:unit": "echo 'Only testing installation of the app'" + "test:unit": "vendor/bin/phpunit -c tests/phpunit.xml --color --fail-on-warning --fail-on-risky" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8", diff --git a/lib/Migration/Version30000Date20240417075404.php b/lib/Migration/Version30000Date20240417075405.php similarity index 75% rename from lib/Migration/Version30000Date20240417075404.php rename to lib/Migration/Version30000Date20240417075405.php index 2ec038d22..ed3c59b9a 100644 --- a/lib/Migration/Version30000Date20240417075404.php +++ b/lib/Migration/Version30000Date20240417075405.php @@ -11,6 +11,7 @@ use Closure; use OCP\DB\ISchemaWrapper; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; @@ -18,7 +19,7 @@ /** * Migrate the photosSourceFolder user config to photosSourceFolders */ -class Version30000Date20240417075404 extends SimpleMigrationStep { +class Version30000Date20240417075405 extends SimpleMigrationStep { public function __construct( private IDBConnection $db, ) { @@ -36,13 +37,26 @@ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array ->where($select->expr()->eq('appid', $select->expr()->literal('photos'))) ->andWhere($select->expr()->eq('configkey', $select->expr()->literal('photosSourceFolders'))); + $result = $select->executeQuery(); + $alreadyMigrated = array_map(static fn (array $row) => $row['userid'], $result->fetchAll()); + $result->closeCursor(); + // Remove old entries for users who already have the new one $delete = $this->db->getQueryBuilder(); $delete->delete('preferences') ->where($delete->expr()->eq('appid', $delete->expr()->literal('photos'))) ->andWhere($delete->expr()->eq('configkey', $delete->expr()->literal('photosSourceFolder'))) - ->andWhere($delete->expr()->in('userid', $delete->createFunction($select->getSQL()))) - ->executeStatement(); + ->andWhere($delete->expr()->in( + 'userid', + $delete->createParameter('chunk'), + IQueryBuilder::PARAM_STR + )); + + $chunks = array_chunk($alreadyMigrated, 1000); + foreach ($chunks as $chunk) { + $delete->setParameter('chunk', $chunk, IQueryBuilder::PARAM_STR_ARRAY); + $delete->executeStatement(); + } // Update remaining old entries to new ones $update = $this->db->getQueryBuilder(); diff --git a/tests/Album/AlbumMapperTest.php b/tests/Album/AlbumMapperTest.php index 05dc5515d..3d4d9cd25 100644 --- a/tests/Album/AlbumMapperTest.php +++ b/tests/Album/AlbumMapperTest.php @@ -17,6 +17,10 @@ use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Files\IMimeTypeLoader; use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\IUserManager; +use OCP\Security\ISecureRandom; use Test\TestCase; /** @@ -30,8 +34,16 @@ class AlbumMapperTest extends TestCase { private $mimeLoader; /** @var AlbumMapper */ private $mapper; - /** @var ITimeFactory|\PHPUnit_Framework_MockObject_MockObject */ + /** @var ITimeFactory|MockObject */ private $timeFactory; + /** @var IUserManager|MockObject */ + private $userManager; + /** @var IGroupManager|MockObject */ + private $groupManager; + /** @var IL10N|MockObject */ + private $l10n; + /** @var ISecureRandom|MockObject */ + private $secureRandom; private int $time = 100; protected function setUp(): void { @@ -41,11 +53,27 @@ protected function setUp(): void { $this->connection = \OC::$server->get(IDBConnection::class); $this->mimeLoader = \OC::$server->get(IMimeTypeLoader::class); $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->l10n = $this->createMock(IL10N::class); + $this->secureRandom = $this->createMock(ISecureRandom::class); $this->timeFactory->method('getTime')->willReturnCallback(function () { return $this->time; }); - $this->mapper = new AlbumMapper($this->connection, $this->mimeLoader, $this->timeFactory); + if ($this->connection->getDatabaseProvider() === IDBConnection::PLATFORM_ORACLE) { + $this->markTestSkipped('Feature is broken on oracle'); + } + + $this->mapper = new AlbumMapper( + $this->connection, + $this->mimeLoader, + $this->timeFactory, + $this->userManager, + $this->groupManager, + $this->l10n, + $this->secureRandom, + ); } protected function tearDown():void { @@ -65,6 +93,7 @@ protected function tearDown():void { } private function createFile(string $name, string $mimeType, int $size = 10, int $mtime = 10000, int $permissions = Constants::PERMISSION_ALL): int { + $mimeId = $this->mimeLoader->getId($mimeType); $mimePartId = $this->mimeLoader->getId(substr($mimeType, strpos($mimeType, '/'))); $query = $this->connection->getQueryBuilder(); @@ -152,133 +181,136 @@ public function testCreateUpdateGet() { $this->assertEquals("nowhere", $retrievedAlbum->getLocation()); } - public function testEmptyFiles() { - $album1 = $this->mapper->create("user1", "album1"); - - $this->assertEquals([new AlbumWithFiles($album1, [])], $this->mapper->getForUserWithFiles("user1")); - } - - public function testAddFiles() { - $album1 = $this->mapper->create("user1", "album1"); - $album2 = $this->mapper->create("user1", "album2"); - - $fileId1 = $this->createFile("file1", "text/plain"); - $fileId2 = $this->createFile("file2", "image/png"); - - $this->mapper->addFile($album1->getId(), $fileId1); - $this->mapper->addFile($album1->getId(), $fileId2); - $this->mapper->addFile($album2->getId(), $fileId1); - - $albumsWithFiles = $this->mapper->getForUserWithFiles("user1"); - usort($albumsWithFiles, function (AlbumWithFiles $a, AlbumWithFiles $b) { - return $a->getAlbum()->getId() <=> $b->getAlbum()->getId(); - }); - $this->assertCount(2, $albumsWithFiles); - - $this->assertEquals($album1->getId(), $albumsWithFiles[0]->getAlbum()->getId()); - $this->assertEquals($fileId2, $albumsWithFiles[0]->getAlbum()->getLastAddedPhoto()); - $files = $albumsWithFiles[0]->getFiles(); - usort($files, function (AlbumFile $a, AlbumFile $b) { - return $a->getFileId() <=> $b->getFileId(); - }); - $this->assertCount(2, $files); - $this->assertEquals(new AlbumFile($fileId1, "file1", "text/plain", 10, 10000, "dummy", 100), $albumsWithFiles[0]->getFiles()[0]); - $this->assertEquals(new AlbumFile($fileId2, "file2", "image/png", 10, 10000, "dummy", 100), $albumsWithFiles[0]->getFiles()[1]); - - $this->assertEquals($album2->getId(), $albumsWithFiles[1]->getAlbum()->getId()); - $this->assertEquals($fileId1, $albumsWithFiles[1]->getAlbum()->getLastAddedPhoto()); - - $files = $albumsWithFiles[1]->getFiles(); - usort($files, function (AlbumFile $a, AlbumFile $b) { - return $a->getFileId() <=> $b->getFileId(); - }); - $this->assertCount(1, $files); - $this->assertEquals(new AlbumFile($fileId1, "file1", "text/plain", 10, 10000, "dummy", 100), $albumsWithFiles[0]->getFiles()[0]); - } - - public function testAddRemoveFiles() { - $album1 = $this->mapper->create("user1", "album1"); - - $fileId1 = $this->createFile("file1", "text/plain"); - $fileId2 = $this->createFile("file2", "image/png"); - $fileId3 = $this->createFile("file3", "image/png"); - - $this->time = 110; - $this->mapper->addFile($album1->getId(), $fileId1); - $this->time = 120; - $this->mapper->addFile($album1->getId(), $fileId2); - $this->time = 130; - $this->mapper->addFile($album1->getId(), $fileId3); - - $albumsWithFiles = $this->mapper->getForUserWithFiles("user1"); - usort($albumsWithFiles, function (AlbumWithFiles $a, AlbumWithFiles $b) { - return $a->getAlbum()->getId() <=> $b->getAlbum()->getId(); - }); - $this->assertCount(1, $albumsWithFiles); - - $this->assertEquals($album1->getId(), $albumsWithFiles[0]->getAlbum()->getId()); - $this->assertEquals($fileId3, $albumsWithFiles[0]->getAlbum()->getLastAddedPhoto()); - $files = $albumsWithFiles[0]->getFiles(); - usort($files, function (AlbumFile $a, AlbumFile $b) { - return $a->getFileId() <=> $b->getFileId(); - }); - $this->assertCount(3, $files); - $this->assertEquals(new AlbumFile($fileId1, "file1", "text/plain", 10, 10000, "dummy", 110), $albumsWithFiles[0]->getFiles()[0]); - $this->assertEquals(new AlbumFile($fileId2, "file2", "image/png", 10, 10000, "dummy", 120), $albumsWithFiles[0]->getFiles()[1]); - $this->assertEquals(new AlbumFile($fileId3, "file3", "image/png", 10, 10000, "dummy", 130), $albumsWithFiles[0]->getFiles()[2]); - - - - $this->mapper->removeFile($album1->getId(), $fileId2); - - $albumsWithFiles = $this->mapper->getForUserWithFiles("user1"); - usort($albumsWithFiles, function (AlbumWithFiles $a, AlbumWithFiles $b) { - return $a->getAlbum()->getId() <=> $b->getAlbum()->getId(); - }); - $this->assertCount(1, $albumsWithFiles); - - $this->assertEquals($album1->getId(), $albumsWithFiles[0]->getAlbum()->getId()); - $this->assertEquals($fileId3, $albumsWithFiles[0]->getAlbum()->getLastAddedPhoto()); - $files = $albumsWithFiles[0]->getFiles(); - usort($files, function (AlbumFile $a, AlbumFile $b) { - return $a->getFileId() <=> $b->getFileId(); - }); - $this->assertCount(2, $files); - $this->assertEquals(new AlbumFile($fileId1, "file1", "text/plain", 10, 10000, "dummy", 110), $albumsWithFiles[0]->getFiles()[0]); - $this->assertEquals(new AlbumFile($fileId3, "file3", "image/png", 10, 10000, "dummy", 130), $albumsWithFiles[0]->getFiles()[1]); - - - - $this->mapper->removeFile($album1->getId(), $fileId3); - - $albumsWithFiles = $this->mapper->getForUserWithFiles("user1"); - usort($albumsWithFiles, function (AlbumWithFiles $a, AlbumWithFiles $b) { - return $a->getAlbum()->getId() <=> $b->getAlbum()->getId(); - }); - $this->assertCount(1, $albumsWithFiles); - - $this->assertEquals($album1->getId(), $albumsWithFiles[0]->getAlbum()->getId()); - $this->assertEquals($fileId1, $albumsWithFiles[0]->getAlbum()->getLastAddedPhoto()); - $files = $albumsWithFiles[0]->getFiles(); - usort($files, function (AlbumFile $a, AlbumFile $b) { - return $a->getFileId() <=> $b->getFileId(); - }); - $this->assertCount(1, $files); - $this->assertEquals(new AlbumFile($fileId1, "file1", "text/plain", 10, 10000, "dummy", 110), $albumsWithFiles[0]->getFiles()[0]); - - - - $this->mapper->removeFile($album1->getId(), $fileId1); - - $albumsWithFiles = $this->mapper->getForUserWithFiles("user1"); - usort($albumsWithFiles, function (AlbumWithFiles $a, AlbumWithFiles $b) { - return $a->getAlbum()->getId() <=> $b->getAlbum()->getId(); - }); - $this->assertCount(1, $albumsWithFiles); - - $this->assertEquals($album1->getId(), $albumsWithFiles[0]->getAlbum()->getId()); - $this->assertEquals(-1, $albumsWithFiles[0]->getAlbum()->getLastAddedPhoto()); - $files = $albumsWithFiles[0]->getFiles(); - $this->assertCount(0, $files); - } + /** + * Disabled as the function does no longer exist + * public function testEmptyFiles() { + * $album1 = $this->mapper->create("user1", "album1"); + * + * $this->assertEquals([new AlbumWithFiles($album1, [])], $this->mapper->getForUserWithFiles("user1")); + * } + * + * public function testAddFiles() { + * $album1 = $this->mapper->create("user1", "album1"); + * $album2 = $this->mapper->create("user1", "album2"); + * + * $fileId1 = $this->createFile("file1", "text/plain"); + * $fileId2 = $this->createFile("file2", "image/png"); + * + * $this->mapper->addFile($album1->getId(), $fileId1, 'user1'); + * $this->mapper->addFile($album1->getId(), $fileId2, 'user1'); + * $this->mapper->addFile($album2->getId(), $fileId1, 'user1'); + * + * $albumsWithFiles = $this->mapper->getForUserWithFiles("user1"); + * usort($albumsWithFiles, function (AlbumWithFiles $a, AlbumWithFiles $b) { + * return $a->getAlbum()->getId() <=> $b->getAlbum()->getId(); + * }); + * $this->assertCount(2, $albumsWithFiles); + * + * $this->assertEquals($album1->getId(), $albumsWithFiles[0]->getAlbum()->getId()); + * $this->assertEquals($fileId2, $albumsWithFiles[0]->getAlbum()->getLastAddedPhoto()); + * $files = $albumsWithFiles[0]->getFiles(); + * usort($files, function (AlbumFile $a, AlbumFile $b) { + * return $a->getFileId() <=> $b->getFileId(); + * }); + * $this->assertCount(2, $files); + * $this->assertEquals(new AlbumFile($fileId1, "file1", "text/plain", 10, 10000, "dummy", 100, 'user1'), $albumsWithFiles[0]->getFiles()[0]); + * $this->assertEquals(new AlbumFile($fileId2, "file2", "image/png", 10, 10000, "dummy", 100, 'user1'), $albumsWithFiles[0]->getFiles()[1]); + * + * $this->assertEquals($album2->getId(), $albumsWithFiles[1]->getAlbum()->getId()); + * $this->assertEquals($fileId1, $albumsWithFiles[1]->getAlbum()->getLastAddedPhoto()); + * + * $files = $albumsWithFiles[1]->getFiles(); + * usort($files, function (AlbumFile $a, AlbumFile $b) { + * return $a->getFileId() <=> $b->getFileId(); + * }); + * $this->assertCount(1, $files); + * $this->assertEquals(new AlbumFile($fileId1, "file1", "text/plain", 10, 10000, "dummy", 100, 'user1'), $albumsWithFiles[0]->getFiles()[0]); + * } + * + * public function testAddRemoveFiles() { + * $album1 = $this->mapper->create("user1", "album1"); + * + * $fileId1 = $this->createFile("file1", "text/plain"); + * $fileId2 = $this->createFile("file2", "image/png"); + * $fileId3 = $this->createFile("file3", "image/png"); + * + * $this->time = 110; + * $this->mapper->addFile($album1->getId(), $fileId1, 'user1'); + * $this->time = 120; + * $this->mapper->addFile($album1->getId(), $fileId2, 'user1'); + * $this->time = 130; + * $this->mapper->addFile($album1->getId(), $fileId3, 'user1'); + * + * $albumsWithFiles = $this->mapper->getForUserWithFiles("user1"); + * usort($albumsWithFiles, function (AlbumWithFiles $a, AlbumWithFiles $b) { + * return $a->getAlbum()->getId() <=> $b->getAlbum()->getId(); + * }); + * $this->assertCount(1, $albumsWithFiles); + * + * $this->assertEquals($album1->getId(), $albumsWithFiles[0]->getAlbum()->getId()); + * $this->assertEquals($fileId3, $albumsWithFiles[0]->getAlbum()->getLastAddedPhoto()); + * $files = $albumsWithFiles[0]->getFiles(); + * usort($files, function (AlbumFile $a, AlbumFile $b) { + * return $a->getFileId() <=> $b->getFileId(); + * }); + * $this->assertCount(3, $files); + * $this->assertEquals(new AlbumFile($fileId1, "file1", "text/plain", 10, 10000, "dummy", 110, 'user1'), $albumsWithFiles[0]->getFiles()[0]); + * $this->assertEquals(new AlbumFile($fileId2, "file2", "image/png", 10, 10000, "dummy", 120, 'user1'), $albumsWithFiles[0]->getFiles()[1]); + * $this->assertEquals(new AlbumFile($fileId3, "file3", "image/png", 10, 10000, "dummy", 130, 'user1'), $albumsWithFiles[0]->getFiles()[2]); + * + * + * + * $this->mapper->removeFile($album1->getId(), $fileId2); + * + * $albumsWithFiles = $this->mapper->getForUserWithFiles("user1"); + * usort($albumsWithFiles, function (AlbumWithFiles $a, AlbumWithFiles $b) { + * return $a->getAlbum()->getId() <=> $b->getAlbum()->getId(); + * }); + * $this->assertCount(1, $albumsWithFiles); + * + * $this->assertEquals($album1->getId(), $albumsWithFiles[0]->getAlbum()->getId()); + * $this->assertEquals($fileId3, $albumsWithFiles[0]->getAlbum()->getLastAddedPhoto()); + * $files = $albumsWithFiles[0]->getFiles(); + * usort($files, function (AlbumFile $a, AlbumFile $b) { + * return $a->getFileId() <=> $b->getFileId(); + * }); + * $this->assertCount(2, $files); + * $this->assertEquals(new AlbumFile($fileId1, "file1", "text/plain", 10, 10000, "dummy", 110, 'user1'), $albumsWithFiles[0]->getFiles()[0]); + * $this->assertEquals(new AlbumFile($fileId3, "file3", "image/png", 10, 10000, "dummy", 130, 'user1'), $albumsWithFiles[0]->getFiles()[1]); + * + * + * + * $this->mapper->removeFile($album1->getId(), $fileId3); + * + * $albumsWithFiles = $this->mapper->getForUserWithFiles("user1"); + * usort($albumsWithFiles, function (AlbumWithFiles $a, AlbumWithFiles $b) { + * return $a->getAlbum()->getId() <=> $b->getAlbum()->getId(); + * }); + * $this->assertCount(1, $albumsWithFiles); + * + * $this->assertEquals($album1->getId(), $albumsWithFiles[0]->getAlbum()->getId()); + * $this->assertEquals($fileId1, $albumsWithFiles[0]->getAlbum()->getLastAddedPhoto()); + * $files = $albumsWithFiles[0]->getFiles(); + * usort($files, function (AlbumFile $a, AlbumFile $b) { + * return $a->getFileId() <=> $b->getFileId(); + * }); + * $this->assertCount(1, $files); + * $this->assertEquals(new AlbumFile($fileId1, "file1", "text/plain", 10, 10000, "dummy", 110, 'user1'), $albumsWithFiles[0]->getFiles()[0]); + * + * + * + * $this->mapper->removeFile($album1->getId(), $fileId1); + * + * $albumsWithFiles = $this->mapper->getForUserWithFiles("user1"); + * usort($albumsWithFiles, function (AlbumWithFiles $a, AlbumWithFiles $b) { + * return $a->getAlbum()->getId() <=> $b->getAlbum()->getId(); + * }); + * $this->assertCount(1, $albumsWithFiles); + * + * $this->assertEquals($album1->getId(), $albumsWithFiles[0]->getAlbum()->getId()); + * $this->assertEquals(-1, $albumsWithFiles[0]->getAlbum()->getLastAddedPhoto()); + * $files = $albumsWithFiles[0]->getFiles(); + * $this->assertCount(0, $files); + * } + */ } diff --git a/tests/Migration/Version30000Date20240417075405Test.php b/tests/Migration/Version30000Date20240417075405Test.php new file mode 100644 index 000000000..4d0d73055 --- /dev/null +++ b/tests/Migration/Version30000Date20240417075405Test.php @@ -0,0 +1,102 @@ +connection = \OCP\Server::get(IDBConnection::class); + } + + protected function tearDown(): void { + $this->deleteTestEntries(); + parent::tearDown(); + } + + protected function deleteTestEntries(): void { + $delete = $this->connection->getQueryBuilder(); + $delete->delete('preferences') + ->where($delete->expr()->like('userid', $delete->createNamedParameter('Version30000Date20240417075405Test%'))); + $delete->executeStatement(); + } + + protected function createTestEntries(): void { + $this->deleteTestEntries(); + + $insert = $this->connection->getQueryBuilder(); + $insert->insert('preferences') + ->values([ + 'userid' => $insert->createParameter('userid'), + 'appid' => $insert->createNamedParameter('photos'), + 'configkey' => $insert->createParameter('configkey'), + 'configvalue' => $insert->createNamedParameter('value'), + ]); + + $this->connection->beginTransaction(); + for ($i = 1; $i <= 3000; $i++) { + $mod = $i % 3; + if ($mod !== 0) { + $insert->setParameter('userid', 'Version30000Date20240417075405Test#' . $i) + ->setParameter('configkey', 'photosSourceFolder'); + $insert->executeStatement(); + } + if ($mod !== 1) { + $insert->setParameter('userid', 'Version30000Date20240417075405Test#' . $i) + ->setParameter('configkey', 'photosSourceFolders'); + $insert->executeStatement(); + } + } + $this->connection->commit(); + } + + public function testPostSchemaChange(): void { + $migration = new Version30000Date20240417075405($this->connection); + + $this->createTestEntries(); + + + $migration->postSchemaChange( + $this->createMock(IOutput::class), + \Closure::fromCallable(fn () => false), + [] + ); + + $select = $this->connection->getQueryBuilder(); + $select->select($select->func()->count()) + ->from('preferences') + ->where($select->expr()->like('userid', $select->createNamedParameter('Version30000Date20240417075405Test%'))) + ->andWhere($select->expr()->eq('appid', $select->expr()->literal('photos'))) + ->andWhere($select->expr()->eq('configkey', $select->expr()->literal('photosSourceFolder'))); + $result = $select->executeQuery(); + $this->assertEquals(0, $result->fetchOne()); + $result->closeCursor(); + + $select = $this->connection->getQueryBuilder(); + $select->select($select->func()->count()) + ->from('preferences') + ->where($select->expr()->like('userid', $select->createNamedParameter('Version30000Date20240417075405Test%'))) + ->andWhere($select->expr()->eq('appid', $select->expr()->literal('photos'))) + ->andWhere($select->expr()->eq('configkey', $select->expr()->literal('photosSourceFolders'))); + $result = $select->executeQuery(); + $this->assertEquals(3000, $result->fetchOne()); + $result->closeCursor(); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 000000000..b3f435f93 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,23 @@ +addValidRoot(OC::$SERVERROOT . '/tests'); + +// Fix for "Autoload path not allowed: .../photos/tests/testcase.php" +\OC_App::loadApp('photos'); + +if (!class_exists('\PHPUnit\Framework\TestCase')) { + require_once('PHPUnit/Autoload.php'); +} + +OC_Hook::clear(); diff --git a/tests/phpunit.xml b/tests/phpunit.xml new file mode 100644 index 000000000..3e7c2ec9c --- /dev/null +++ b/tests/phpunit.xml @@ -0,0 +1,13 @@ + + + + . + + +