├── test ├── res │ ├── composer │ │ ├── no-klein │ │ │ ├── vendor │ │ │ │ └── .gitkeep │ │ │ ├── solution.php │ │ │ └── composer.json │ │ ├── good-solution │ │ │ ├── vendor │ │ │ │ └── .gitkeep │ │ │ └── composer.json │ │ ├── no-stringy │ │ │ ├── vendor │ │ │ │ └── .gitkeep │ │ │ ├── solution.php │ │ │ └── composer.json │ │ ├── no-vendor │ │ │ ├── composer.json │ │ │ └── solution.php │ │ └── not-locked │ │ │ ├── composer.json │ │ │ └── solution.php │ ├── cgi │ │ ├── user-error.php │ │ ├── solution-error.php │ │ ├── get-solution.php │ │ ├── get-user-wrong.php │ │ ├── post-solution.php │ │ ├── post-multiple-solution.php │ │ ├── get-solution-header.php │ │ └── get-user-header-wrong.php │ ├── cli │ │ ├── user-wrong.php │ │ ├── user.php │ │ ├── solution.php │ │ ├── user-error.php │ │ └── solution-error.php │ ├── database │ │ ├── user-wrong.php │ │ ├── user.php │ │ ├── solution.php │ │ ├── user-error.php │ │ ├── solution-error.php │ │ ├── solution-alter-db.php │ │ └── user-solution-alter-db.php │ ├── lint │ │ ├── fail.php │ │ └── pass.php │ ├── function-requirements │ │ ├── fail-invalid-code.php │ │ ├── fail-banned-function.php │ │ └── success.php │ ├── app-run-missing-solution-expected.txt │ ├── app-credits-core-expected.txt │ ├── app-credits-expected.txt │ ├── exercise-help-expected.txt │ ├── exercise-renderer │ │ ├── test-render-all-failures.txt │ │ ├── test-render-success.txt │ │ ├── test-render-success-and-failure.txt │ │ ├── test-render-success-with-solution.txt │ │ ├── test-render-success-with-php-solution-file-is-syntax-highlighted.txt │ │ └── test-all-success-results-are-hoisted-to-the-top.txt │ └── app-help-expected.txt ├── Asset │ ├── provided-solution │ │ └── solution.php │ ├── initial-code │ │ └── init-solution.php │ ├── SelfCheckExerciseInterface.php │ ├── TemporaryDirectoryTraitImpl.php │ ├── ResultResultAggregator.php │ ├── AbstractExerciseImpl.php │ ├── CliExerciseMissingInterface.php │ ├── CustomVerifyingExerciseImpl.php │ ├── PatchableExercise.php │ ├── MockEventDispatcher.php │ ├── ProvidesSolutionExercise.php │ ├── ExerciseWithInitialCode.php │ └── ComposerExercise.php ├── bootstrap.php ├── ExerciseRunner │ ├── Context │ │ └── NoEntryPointTest.php │ └── CustomVerifyingRunnerTest.php ├── Exception │ ├── SolutionExecutionExceptionTest.php │ ├── CliRouteNotExistsTest.php │ ├── SolutionFileDoesNotExistExceptionTest.php │ ├── MissingArgumentExceptionTest.php │ ├── ExerciseNotConfiguredExceptionTest.php │ ├── CheckNotApplicableExceptionTest.php │ └── CodeExecutionExceptionTest.php ├── TestWorkshopType.php ├── Process │ ├── ProcessNotFoundExceptionTest.php │ └── ProcessInputTest.php ├── Command │ ├── MenuCommandTest.php │ ├── HelpCommandTest.php │ ├── MenuCommandInvokerTest.php │ └── PrintCommandTest.php ├── Exercise │ ├── TemporaryDirectoryTraitTest.php │ └── Scenario │ │ ├── CgiScenarioTest.php │ │ └── CliScenarioTest.php ├── ResultRenderer │ ├── FailureRendererTest.php │ ├── ComparisonFailureRendererTest.php │ ├── Cli │ │ └── RequestFailureRendererTest.php │ ├── FileComparisonFailureRendererTest.php │ └── FunctionRequirementsFailureRendererTest.php ├── Result │ ├── Cli │ │ ├── SuccessTest.php │ │ ├── CliResultTest.php │ │ ├── RequestFailureTest.php │ │ └── GenericFailureTest.php │ ├── Cgi │ │ ├── SuccessTest.php │ │ └── CgiResultTest.php │ ├── FileComparisonFailureTest.php │ ├── FunctionRequirementsFailureTest.php │ ├── SuccessTest.php │ └── ComparisonFailureTest.php ├── Markdown │ ├── Shorthands │ │ ├── Cloud │ │ │ ├── RunTest.php │ │ │ └── VerifyTest.php │ │ ├── Cli │ │ │ ├── RunTest.php │ │ │ └── VerifyTest.php │ │ └── ContextTest.php │ ├── CurrentContextTest.php │ └── Block │ │ └── ContextSpecificBlockTest.php ├── CommandArgumentTest.php ├── CodeInsertionTest.php ├── Solution │ └── SingleFileSolutionTest.php ├── Event │ ├── ContainerListenerHelperTest.php │ ├── ExerciseRunnerEventTest.php │ ├── EventTest.php │ ├── CgiExerciseRunnerEventTest.php │ ├── CliExerciseRunnerEventTest.php │ └── CliExecuteEventTest.php ├── MarkdownRendererTest.php ├── MockLogger.php ├── Listener │ ├── TearDownListenerTest.php │ └── SelfCheckListenerTest.php ├── Utils │ ├── SystemTest.php │ └── PathTest.php ├── ComposerUtil │ └── LockFileParserTest.php ├── Patch │ └── ForceStrictTypesTest.php ├── BaseTest.php ├── Logger │ └── ConsoleLoggerTest.php ├── Input │ └── InputTest.php └── FunctionsTest.php ├── codecov.yml ├── .gitignore ├── vendor-bin └── php-cs-fixer │ └── composer.json ├── src ├── Exception │ ├── RuntimeException.php │ ├── SolutionExecutionException.php │ ├── ExerciseNotAssignedException.php │ ├── ProblemFileDoesNotExistException.php │ ├── CliRouteNotExistsException.php │ ├── SolutionFileDoesNotExistException.php │ ├── CouldNotRunException.php │ ├── CodeExecutionException.php │ ├── ExerciseNotConfiguredException.php │ ├── CheckNotApplicableException.php │ └── MissingArgumentException.php ├── UserState │ └── Serializer.php ├── Process │ ├── ProcessFactory.php │ ├── ProcessNotFoundException.php │ ├── ProcessInput.php │ └── HostProcessFactory.php ├── Utils │ ├── Collection.php │ ├── Path.php │ ├── System.php │ └── RequestRenderer.php ├── Patch │ ├── Transformer.php │ ├── ForceStrictTypes.php │ └── WrapInTryCatch.php ├── Result │ ├── SuccessInterface.php │ ├── Cgi │ │ ├── SuccessInterface.php │ │ ├── FailureInterface.php │ │ ├── ResultInterface.php │ │ └── Success.php │ ├── Cli │ │ ├── SuccessInterface.php │ │ ├── FailureInterface.php │ │ ├── ResultInterface.php │ │ └── Success.php │ ├── FailureInterface.php │ ├── ResultGroupInterface.php │ ├── ResultInterface.php │ ├── ResultTrait.php │ └── Success.php ├── ExerciseRunner │ ├── Context │ │ └── NoEntryPoint.php │ └── Factory │ │ ├── ExerciseRunnerFactoryInterface.php │ │ └── CustomVerifyingRunnerFactory.php ├── Event │ ├── functions.php │ ├── CgiExerciseRunnerEvent.php │ ├── CliExerciseRunnerEvent.php │ ├── EventInterface.php │ ├── ContainerListenerHelper.php │ ├── ExerciseRunnerEvent.php │ └── Event.php ├── Exercise │ ├── CustomVerifyingExercise.php │ ├── ProvidesSolution.php │ ├── ProvidesInitialCode.php │ ├── MockExercise.php │ ├── Scenario │ │ ├── ExerciseScenario.php │ │ ├── CgiScenario.php │ │ └── CliScenario.php │ ├── TemporaryDirectoryTrait.php │ ├── CgiExercise.php │ ├── SubmissionPatchable.php │ ├── CliExercise.php │ ├── ExerciseType.php │ └── ExerciseInterface.php ├── ExerciseCheck │ ├── FileComparisonExerciseCheck.php │ ├── ComposerExerciseCheck.php │ ├── FunctionRequirementsExerciseCheck.php │ ├── SelfCheck.php │ └── DatabaseExerciseCheck.php ├── Markdown │ ├── Shorthands │ │ ├── ShorthandInterface.php │ │ ├── Cloud │ │ │ ├── AppName.php │ │ │ ├── Run.php │ │ │ └── Verify.php │ │ ├── Context.php │ │ ├── Cli │ │ │ ├── Run.php │ │ │ ├── Verify.php │ │ │ └── AppName.php │ │ └── Documentation.php │ ├── CurrentContext.php │ ├── Renderer │ │ └── ContextSpecificRenderer.php │ ├── Block │ │ └── ContextSpecificBlock.php │ ├── Parser │ │ └── ContextSpecificBlockParser.php │ └── ProblemFileExtension.php ├── Check │ ├── CheckInterface.php │ └── ListenableCheckInterface.php ├── WorkshopType.php ├── Listener │ ├── TearDownListener.php │ ├── RealPathListener.php │ ├── SelfCheckListener.php │ ├── CheckExerciseAssignedListener.php │ ├── LazyContainerListener.php │ └── PrepareSolutionListener.php ├── Command │ ├── MenuCommand.php │ └── MenuCommandInvoker.php ├── ResultRenderer │ ├── ResultRendererInterface.php │ ├── FailureRenderer.php │ ├── ComposerFailureRenderer.php │ └── ComparisonFailureRenderer.php ├── Output │ ├── NullOutput.php │ ├── BufferedOutput.php │ └── OutputInterface.php ├── Solution │ └── SolutionInterface.php ├── Logger │ ├── ConsoleLogger.php │ └── Logger.php ├── MenuItem │ └── ResetProgress.php ├── MarkdownRenderer.php └── CommandArgument.php ├── .phpstorm.meta.php ├── .php-cs-fixer.php ├── phpstan.neon ├── phpunit.xml ├── LICENSE ├── README.md └── .github └── workflows └── php-workshop.yml /test/res/composer/no-klein/vendor/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/res/composer/good-solution/vendor/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/res/composer/no-stringy/vendor/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/res/cgi/user-error.php: -------------------------------------------------------------------------------- 1 | prepare('INSERT into users (name, age, gender) VALUES (:name, :age, :gender)'); 6 | $stmt->execute([':name' => 'Jim Morrison', ':age' => 27, ':gender' => 'Male']); 7 | -------------------------------------------------------------------------------- /test/res/database/user-solution-alter-db.php: -------------------------------------------------------------------------------- 1 | prepare('INSERT into users (name, age, gender) VALUES (:name, :age, :gender)'); 6 | $stmt->execute([':name' => 'Kurt Cobain', ':age' => 27, ':gender' => 'Male']); 7 | -------------------------------------------------------------------------------- /src/Process/ProcessFactory.php: -------------------------------------------------------------------------------- 1 | [ 6 | "" == "@", 7 | ], 8 | \Psr\Container\ContainerInterface::get('') => [ 9 | "" == "@", 10 | ], 11 | ]; 12 | } 13 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->exclude('test/res') 6 | ; 7 | 8 | return (new PhpCsFixer\Config()) 9 | ->setRules([ 10 | '@PER-CS2.0' => true, 11 | 'no_unused_imports' => true, 12 | ]) 13 | ->setFinder($finder); -------------------------------------------------------------------------------- /src/Utils/Collection.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class Collection extends ArrayObject {} 14 | -------------------------------------------------------------------------------- /src/Patch/Transformer.php: -------------------------------------------------------------------------------- 1 | $statements 11 | * @return array 12 | */ 13 | public function transform(array $statements): array; 14 | } 15 | -------------------------------------------------------------------------------- /test/Asset/TemporaryDirectoryTraitImpl.php: -------------------------------------------------------------------------------- 1 | new ContainerListenerHelper($service, $method); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/res/composer/no-klein/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "learn-you-php/dependency-heaven", 3 | "description": "String manipulation with Composer", 4 | "require": { 5 | "danielstjules/stringy": "^2.1" 6 | }, 7 | "authors": [ 8 | { 9 | "name": "Michael Woodward", 10 | "email": "mikeymike.mw@gmail.com" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/Exception/SolutionExecutionException.php: -------------------------------------------------------------------------------- 1 | getMessage()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/Exception/SolutionExecutionExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('nope', $e->getMessage()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Exercise/ProvidesSolution.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | public function toArray(): array; 19 | } 20 | -------------------------------------------------------------------------------- /test/Exception/CliRouteNotExistsTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('Command: "some-route" does not exist', $e->getMessage()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Exception/ExerciseNotAssignedException.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | public function toArray(): array; 19 | } 20 | -------------------------------------------------------------------------------- /src/Result/Cli/FailureInterface.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | public function toArray(): array; 19 | } 20 | -------------------------------------------------------------------------------- /src/Utils/Path.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public function getFilesToCompare(): array; 17 | } 18 | -------------------------------------------------------------------------------- /src/Result/Cgi/ResultInterface.php: -------------------------------------------------------------------------------- 1 | name = $name; 17 | } 18 | 19 | public function getName(): string 20 | { 21 | return $this->name; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | treatPhpDocTypesAsCertain: false 3 | ignoreErrors: 4 | - 5 | message: '#Call to an undefined method PhpParser\\Node\\Expr\|PhpParser\\Node\\Name\:\:__toString\(\)#' 6 | path: src/Check/FunctionRequirementsCheck.php 7 | 8 | - 9 | message: '#Class PHPUnit\\Framework\\TestCase not found#' 10 | path: src/TestUtils/WorkshopExerciseTest.php 11 | 12 | excludePaths: 13 | - src/TestUtils/WorkshopExerciseTest.php 14 | -------------------------------------------------------------------------------- /src/Markdown/Shorthands/ShorthandInterface.php: -------------------------------------------------------------------------------- 1 | $callArgs 18 | * @return array 19 | */ 20 | public function __invoke(array $callArgs): array; 21 | } 22 | -------------------------------------------------------------------------------- /src/Result/Cli/ResultInterface.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public function getArgs(): ArrayObject; 20 | } 21 | -------------------------------------------------------------------------------- /test/TestWorkshopType.php: -------------------------------------------------------------------------------- 1 | isTutorialMode()); 16 | static::assertFalse($standard->isTutorialMode()); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/Process/ProcessNotFoundExceptionTest.php: -------------------------------------------------------------------------------- 1 | getMessage()); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | ./test 7 | 8 | 9 | 10 | ./src 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Check/CheckInterface.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public function getResults(): array; 21 | 22 | public function isResultSuccess(ResultInterface $result): bool; 23 | } 24 | -------------------------------------------------------------------------------- /src/Exercise/ProvidesInitialCode.php: -------------------------------------------------------------------------------- 1 | createMock(CliMenu::class); 14 | $menu 15 | ->expects($this->once()) 16 | ->method('open'); 17 | 18 | $command = new MenuCommand($menu); 19 | $command->__invoke(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/Exception/SolutionFileDoesNotExistExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('File: "some-file.csv" does not exist in solution folder', $e->getMessage()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/Exercise/TemporaryDirectoryTraitTest.php: -------------------------------------------------------------------------------- 1 | getTemporaryPath(); 14 | 15 | mkdir($path, 0775, true); 16 | $this->assertFileExists($path); 17 | rmdir($path); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Result/ResultInterface.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class WorkshopType extends Enum 16 | { 17 | public const STANDARD = 1; 18 | public const TUTORIAL = 2; 19 | 20 | /** 21 | * @return bool 22 | */ 23 | public function isTutorialMode(): bool 24 | { 25 | return $this->getValue() === static::TUTORIAL; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/res/exercise-help-expected.txt: -------------------------------------------------------------------------------- 1 | 2 |  LEARN YOU THE PHP FOR MUCH WIN!  3 | ********************************* 4 | 5 |   6 |  Exercise 2 of 2 7 | 8 |  9 | ### Exercise Content 10 | 11 | 12 | » To print these instructions again, run: phpschool print 13 | » To execute your program in a test environment, run: phpschool run program.php 14 | » To verify your program, run: phpschool verify program.php 15 | » For help run: phpschool help 16 | 17 | 18 | -------------------------------------------------------------------------------- /test/ResultRenderer/FailureRendererTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(" Something went wrong\n", $renderer->render($this->getRenderer())); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Listener/TearDownListener.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 20 | } 21 | 22 | public function cleanupTempDir(): void 23 | { 24 | $this->filesystem->remove(System::tempDir()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Exercise/MockExercise.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | private array $files = []; 11 | 12 | public function withFile(string $relativeFileName, string $content): static 13 | { 14 | $this->files[$relativeFileName] = $content; 15 | 16 | return $this; 17 | } 18 | 19 | /** 20 | * @return array 21 | */ 22 | public function getFiles(): array 23 | { 24 | return $this->files; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/ExerciseCheck/ComposerExerciseCheck.php: -------------------------------------------------------------------------------- 1 | An array of composer package names. 18 | */ 19 | public function getRequiredPackages(): array; 20 | } 21 | -------------------------------------------------------------------------------- /src/Markdown/Shorthands/Cloud/AppName.php: -------------------------------------------------------------------------------- 1 | $callArgs 14 | * @return array 15 | */ 16 | public function __invoke(array $callArgs): array 17 | { 18 | return []; 19 | } 20 | 21 | public function getCode(): string 22 | { 23 | return 'appname'; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/Result/Cli/SuccessTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(Success::class, $success); 16 | $this->assertSame($args, $success->getArgs()); 17 | $this->assertEquals('CLI Program Runner', $success->getCheckName()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Command/MenuCommand.php: -------------------------------------------------------------------------------- 1 | menu = $menu; 25 | } 26 | 27 | /** 28 | * @return void 29 | */ 30 | public function __invoke(): void 31 | { 32 | $this->menu->open(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Check/ListenableCheckInterface.php: -------------------------------------------------------------------------------- 1 | createMock(RequestInterface::class); 14 | $success = new Success($request); 15 | $this->assertInstanceOf(Success::class, $success); 16 | $this->assertSame($request, $success->getRequest()); 17 | $this->assertEquals('CGI Program Runner', $success->getCheckName()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Exercise/Scenario/CgiScenario.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | private array $executions = []; 13 | 14 | public function withExecution(RequestInterface $request): self 15 | { 16 | $this->executions[] = $request; 17 | 18 | return $this; 19 | } 20 | 21 | /** 22 | * @return array 23 | */ 24 | public function getExecutions(): array 25 | { 26 | return $this->executions; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/Markdown/Shorthands/Cloud/RunTest.php: -------------------------------------------------------------------------------- 1 | __invoke([]); 16 | self::assertCount(1, $result); 17 | self::assertInstanceOf(Text::class, $result[0]); 18 | self::assertEquals('Click the Run button in the bottom right', $result[0]->getContent()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Exercise/TemporaryDirectoryTrait.php: -------------------------------------------------------------------------------- 1 | assertEquals( 14 | 'Command: "some-route" is missing the following arguments: "arg1", "arg2"', 15 | $e->getMessage(), 16 | ); 17 | 18 | $this->assertSame(['arg1', 'arg2'], $e->getMissingArguments()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/Markdown/Shorthands/Cloud/VerifyTest.php: -------------------------------------------------------------------------------- 1 | __invoke([]); 16 | self::assertCount(1, $result); 17 | self::assertInstanceOf(Text::class, $result[0]); 18 | self::assertEquals('Click the Verify button in the bottom right', $result[0]->getContent()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Exception/SolutionFileDoesNotExistException.php: -------------------------------------------------------------------------------- 1 | assertSame('arg1', $arg->getName()); 14 | $this->assertFalse($arg->isOptional()); 15 | } 16 | 17 | public function testOptionalArgument(): void 18 | { 19 | $arg = new CommandArgument('arg1', true); 20 | $this->assertSame('arg1', $arg->getName()); 21 | $this->assertTrue($arg->isOptional()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Markdown/Shorthands/Cloud/Run.php: -------------------------------------------------------------------------------- 1 | $callArgs 14 | * @return Text[] 15 | */ 16 | public function __invoke(array $callArgs): array 17 | { 18 | return [ 19 | new Text('Click the Run button in the bottom right'), 20 | ]; 21 | } 22 | 23 | public function getCode(): string 24 | { 25 | return 'run'; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Markdown/Shorthands/Cloud/Verify.php: -------------------------------------------------------------------------------- 1 | $callArgs 14 | * @return Text[] 15 | */ 16 | public function __invoke(array $callArgs): array 17 | { 18 | return [ 19 | new Text('Click the Verify button in the bottom right'), 20 | ]; 21 | } 22 | 23 | public function getCode(): string 24 | { 25 | return 'verify'; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Exception/CouldNotRunException.php: -------------------------------------------------------------------------------- 1 | failure = $failure; 16 | parent::__construct('Could not run exercise'); 17 | } 18 | 19 | public static function fromFailure(FailureInterface $failure): self 20 | { 21 | return new self($failure); 22 | } 23 | 24 | public function getFailure(): FailureInterface 25 | { 26 | return $this->failure; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Exercise/CgiExercise.php: -------------------------------------------------------------------------------- 1 | withExecution($request1) 23 | * ``` 24 | */ 25 | public function defineTestScenario(): CgiScenario; 26 | } 27 | -------------------------------------------------------------------------------- /src/Exercise/Scenario/CliScenario.php: -------------------------------------------------------------------------------- 1 | > 11 | */ 12 | private array $executions = []; 13 | 14 | /** 15 | * @param array $args 16 | */ 17 | public function withExecution(array $args = []): static 18 | { 19 | $this->executions[] = new Collection($args); 20 | 21 | return $this; 22 | } 23 | 24 | /** 25 | * @return array> 26 | */ 27 | public function getExecutions(): array 28 | { 29 | return $this->executions; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/CodeInsertionTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidArgumentException::class); 14 | new CodeInsertion('notatype', ''); 15 | } 16 | 17 | public function testGetters(): void 18 | { 19 | $mod = new CodeInsertion(CodeInsertion::TYPE_BEFORE, 'assertEquals(CodeInsertion::TYPE_BEFORE, $mod->getType()); 21 | $this->assertEquals('getCode()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/Process/ProcessInputTest.php: -------------------------------------------------------------------------------- 1 | 'value'], 'input'); 13 | 14 | static::assertSame('composer', $input->getExecutable()); 15 | static::assertSame(['one', 'two'], $input->getArgs()); 16 | static::assertSame(__DIR__, $input->getWorkingDirectory()); 17 | static::assertSame(['SOME_VAR' => 'value'], $input->getEnv()); 18 | static::assertSame('input', $input->getInput()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Command/MenuCommandInvoker.php: -------------------------------------------------------------------------------- 1 | command = $command; 25 | } 26 | 27 | /** 28 | * @param CliMenu $menu 29 | */ 30 | public function __invoke(CliMenu $menu): void 31 | { 32 | $menu->close(); 33 | $command = $this->command; 34 | $command(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Listener/RealPathListener.php: -------------------------------------------------------------------------------- 1 | getInput()->hasArgument('program')) { 20 | return; 21 | } 22 | 23 | $program = $event->getInput()->getRequiredArgument('program'); 24 | 25 | if (file_exists($program)) { 26 | $event->getInput()->setArgument('program', (string) realpath($program)); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/res/composer/no-vendor/solution.php: -------------------------------------------------------------------------------- 1 | respond('POST', '/reverse', function (Request $req, Response $res) { 14 | $res->json(['result' => (new S($req->param('data', '')))->reverse()]); 15 | }); 16 | 17 | $klein->respond('POST', '/swapcase', function (Request $req, Response $res) { 18 | $res->json(['result' => (new S($req->param('data', '')))->swapCase()]); 19 | }); 20 | 21 | $klein->respond('POST', '/titleize', function (Request $req, Response $res) { 22 | $res->json(['result' => (new S($req->param('data', '')))->titleize()]); 23 | }); 24 | -------------------------------------------------------------------------------- /test/res/composer/not-locked/solution.php: -------------------------------------------------------------------------------- 1 | respond('POST', '/reverse', function (Request $req, Response $res) { 14 | $res->json(['result' => (new S($req->param('data', '')))->reverse()]); 15 | }); 16 | 17 | $klein->respond('POST', '/swapcase', function (Request $req, Response $res) { 18 | $res->json(['result' => (new S($req->param('data', '')))->swapCase()]); 19 | }); 20 | 21 | $klein->respond('POST', '/titleize', function (Request $req, Response $res) { 22 | $res->json(['result' => (new S($req->param('data', '')))->titleize()]); 23 | }); 24 | -------------------------------------------------------------------------------- /src/Result/ResultTrait.php: -------------------------------------------------------------------------------- 1 | check->getName(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/Solution/SingleFileSolutionTest.php: -------------------------------------------------------------------------------- 1 | getTemporaryFile('test.file', 'FILE CONTENTS'); 13 | 14 | $solution = SingleFileSolution::fromFile($filePath); 15 | 16 | self::assertSame('FILE CONTENTS', file_get_contents($solution->getEntryPoint())); 17 | self::assertFalse($solution->hasComposerFile()); 18 | self::assertCount(1, $solution->getFiles()); 19 | self::assertSame('FILE CONTENTS', file_get_contents($solution->getFiles()[0]->__toString())); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Exercise/SubmissionPatchable.php: -------------------------------------------------------------------------------- 1 | expectOutputString(file_get_contents(__DIR__ . '/../res/app-help-expected.txt')); 16 | 17 | $color = new Color(); 18 | $color->setForceStyle(true); 19 | 20 | $command = new HelpCommand( 21 | 'learnyouphp', 22 | new StdOutput($color, $this->createMock(Terminal::class)), 23 | $color, 24 | ); 25 | 26 | $command->__invoke(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Event/CgiExerciseRunnerEvent.php: -------------------------------------------------------------------------------- 1 | $parameters 14 | */ 15 | public function __construct( 16 | string $name, 17 | ExecutionContext $context, 18 | CgiScenario $scenario, 19 | array $parameters = [], 20 | ) { 21 | $this->scenario = $scenario; 22 | parent::__construct($name, $context, $parameters); 23 | } 24 | 25 | public function getScenario(): CgiScenario 26 | { 27 | return $this->scenario; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Event/CliExerciseRunnerEvent.php: -------------------------------------------------------------------------------- 1 | $parameters 14 | */ 15 | public function __construct( 16 | string $name, 17 | ExecutionContext $context, 18 | CliScenario $scenario, 19 | array $parameters = [], 20 | ) { 21 | $this->scenario = $scenario; 22 | parent::__construct($name, $context, $parameters); 23 | } 24 | 25 | public function getScenario(): CliScenario 26 | { 27 | return $this->scenario; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/ResultRenderer/ResultRendererInterface.php: -------------------------------------------------------------------------------- 1 | withExecution(['arg1', 'arg2']) 23 | * ->withExecution(['round2-arg1', 'round2-arg2']) 24 | * ``` 25 | */ 26 | public function defineTestScenario(): CliScenario; 27 | } 28 | -------------------------------------------------------------------------------- /test/Command/MenuCommandInvokerTest.php: -------------------------------------------------------------------------------- 1 | createMock(CliMenu::class); 14 | $menu 15 | ->expects($this->once()) 16 | ->method('close'); 17 | 18 | $command = $this->getMockBuilder('stdClass') 19 | ->setMethods(['__invoke']) 20 | ->getMock(); 21 | 22 | $command 23 | ->expects($this->once()) 24 | ->method('__invoke'); 25 | 26 | $invoker = new MenuCommandInvoker($command); 27 | $invoker->__invoke($menu); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/Event/ContainerListenerHelperTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('Some\Object', $helper->getService()); 15 | $this->assertEquals('__invoke', $helper->getMethod()); 16 | } 17 | 18 | public function testWithCustomMethod(): void 19 | { 20 | $helper = new ContainerListenerHelper('Some\Object', 'myMethod'); 21 | 22 | $this->assertEquals('Some\Object', $helper->getService()); 23 | $this->assertEquals('myMethod', $helper->getMethod()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Utils/System.php: -------------------------------------------------------------------------------- 1 | __invoke(); 17 | 18 | $renderer = new MarkdownRenderer($docParser, $cliRenderer); 19 | 20 | $markdown = "### HONEY BADGER DON'T CARE"; 21 | $expected = "\n### HONEY BADGER DON'T CARE\n\n"; 22 | 23 | $this->assertSame($expected, $renderer->render($markdown)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/MockLogger.php: -------------------------------------------------------------------------------- 1 | }> 14 | */ 15 | public $messages = []; 16 | 17 | public function log($level, $message, array $context = []): void 18 | { 19 | $this->messages[] = [ 20 | 'level' => $level, 21 | 'message' => $message, 22 | 'context' => $context, 23 | ]; 24 | } 25 | 26 | /** 27 | * @return array}> 28 | */ 29 | public function getLastMessage(): array 30 | { 31 | return $this->messages[count($this->messages) - 1]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/ResultRenderer/ComparisonFailureRendererTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expected, $renderer->render($this->getRenderer())); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/ResultRenderer/FailureRenderer.php: -------------------------------------------------------------------------------- 1 | result = $result; 25 | } 26 | 27 | /** 28 | * Simply print the reason. 29 | * 30 | * @param ResultsRenderer $renderer 31 | * @return string 32 | */ 33 | public function render(ResultsRenderer $renderer): string 34 | { 35 | return $renderer->center((string) $this->result->getReason()) . "\n"; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/Result/FileComparisonFailureTest.php: -------------------------------------------------------------------------------- 1 | createMock(CheckInterface::class); 16 | $check 17 | ->method('getName') 18 | ->willReturn('Some Check'); 19 | 20 | $failure = new FileComparisonFailure($check, 'users.txt', 'Expected Output', 'Actual Output'); 21 | $this->assertEquals('Expected Output', $failure->getExpectedValue()); 22 | $this->assertEquals('Actual Output', $failure->getActualValue()); 23 | $this->assertEquals('users.txt', $failure->getFileName()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Listener/SelfCheckListener.php: -------------------------------------------------------------------------------- 1 | getContext()->getExercise(); 22 | 23 | if ($exercise instanceof SelfCheck) { 24 | /** @var Input $input */ 25 | $input = $event->getParameter('input'); 26 | $this->results->add($exercise->check($event->getContext())); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/Result/FunctionRequirementsFailureTest.php: -------------------------------------------------------------------------------- 1 | createMock(CheckInterface::class); 14 | $check 15 | ->method('getName') 16 | ->willReturn('Some Check'); 17 | 18 | $failure = new FunctionRequirementsFailure($check, ['function' => 'file', 'line' => 3], ['explode']); 19 | $this->assertEquals(['function' => 'file', 'line' => 3], $failure->getBannedFunctions()); 20 | $this->assertEquals(['explode'], $failure->getMissingFunctions()); 21 | $this->assertSame('Some Check', $failure->getCheckName()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Event/EventInterface.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | public function getParameters(): array; 27 | 28 | /** 29 | * Get a parameter by its name. 30 | * 31 | * @param string $name The name of the parameter. 32 | * @return mixed The value. 33 | * @throws InvalidArgumentException If the parameter by name does not exist. 34 | */ 35 | public function getParameter(string $name): mixed; 36 | } 37 | -------------------------------------------------------------------------------- /src/Exception/CodeExecutionException.php: -------------------------------------------------------------------------------- 1 | getErrorOutput() ?: $process->getOutput()), 27 | ), 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Event/ContainerListenerHelper.php: -------------------------------------------------------------------------------- 1 | service = $service; 29 | $this->method = $method; 30 | } 31 | 32 | /** 33 | * @return string 34 | */ 35 | public function getService(): string 36 | { 37 | return $this->service; 38 | } 39 | 40 | /** 41 | * @return string 42 | */ 43 | public function getMethod(): string 44 | { 45 | return $this->method; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Exception/ExerciseNotConfiguredException.php: -------------------------------------------------------------------------------- 1 | getName(), $interface)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Output/NullOutput.php: -------------------------------------------------------------------------------- 1 | 1]); 16 | self::assertSame($context, $event->getContext()); 17 | self::assertSame($context->getExercise(), $event->getExercise()); 18 | self::assertSame($context->getInput(), $event->getInput()); 19 | self::assertEquals( 20 | [ 21 | 'exercise' => $context->getExercise(), 22 | 'input' => $context->getInput(), 23 | 'number' => 1, 24 | ], 25 | $event->getParameters(), 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Markdown/Shorthands/Context.php: -------------------------------------------------------------------------------- 1 | currentContext = $currentContext; 20 | } 21 | 22 | public function __invoke(array $callArgs): array 23 | { 24 | $offset = array_search($this->currentContext->get(), $callArgs, true); 25 | 26 | if (false === $offset || !is_int($offset) || !isset($callArgs[$offset + 1])) { 27 | return []; 28 | } 29 | 30 | return [new Text($callArgs[$offset + 1])]; 31 | } 32 | 33 | public function getCode(): string 34 | { 35 | return 'context'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/Result/SuccessTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(ResultInterface::class, $success); 16 | $this->assertEquals('Some Check', $success->getCheckName()); 17 | } 18 | 19 | public function testSuccessFromCheck(): void 20 | { 21 | $check = $this->createMock(CheckInterface::class); 22 | $check 23 | ->method('getName') 24 | ->willReturn('Some Check'); 25 | 26 | $success = Success::fromCheck($check); 27 | $this->assertInstanceOf(ResultInterface::class, $success); 28 | $this->assertEquals('Some Check', $success->getCheckName()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/Result/ComparisonFailureTest.php: -------------------------------------------------------------------------------- 1 | getCheckName()); 14 | self::assertEquals('Expected Output', $failure->getExpectedValue()); 15 | self::assertEquals('Actual Output', $failure->getActualValue()); 16 | } 17 | 18 | public function testFailureFromArgsAndOutput(): void 19 | { 20 | $failure = ComparisonFailure::fromNameAndValues('Name', 'Expected Output', 'Actual Output'); 21 | self::assertSame('Name', $failure->getCheckName()); 22 | self::assertEquals('Expected Output', $failure->getExpectedValue()); 23 | self::assertEquals('Actual Output', $failure->getActualValue()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/ResultRenderer/Cli/RequestFailureRendererTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expected, $renderer->render($this->getRenderer())); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/ExerciseCheck/FunctionRequirementsExerciseCheck.php: -------------------------------------------------------------------------------- 1 | An array of function names that *should* be used. 18 | */ 19 | public function getRequiredFunctions(): array; 20 | 21 | /** 22 | * Returns an array of function names that the student's solution should not use. The solution 23 | * will be parsed and checked for usages of these functions. 24 | * 25 | * @return array An array of function names that *should not* be used. 26 | */ 27 | public function getBannedFunctions(): array; 28 | } 29 | -------------------------------------------------------------------------------- /src/Solution/SolutionInterface.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | public function getFiles(): array; 26 | 27 | /** 28 | * Get the absolute path to the directory containing the solution. 29 | * 30 | * @return string 31 | */ 32 | public function getBaseDirectory(): string; 33 | 34 | /** 35 | * Whether or not the solution has a `composer.json` & `composer.lock` file. 36 | * 37 | * @return bool 38 | */ 39 | public function hasComposerFile(): bool; 40 | } 41 | -------------------------------------------------------------------------------- /test/Exception/ExerciseNotConfiguredExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('nope', $e->getMessage()); 15 | } 16 | 17 | public function testMissingImplementsConstructor(): void 18 | { 19 | $exercise = $this->createMock(ExerciseInterface::class); 20 | $exercise 21 | ->expects($this->once()) 22 | ->method('getName') 23 | ->willReturn('Some Exercise'); 24 | 25 | $e = ExerciseNotConfiguredException::missingImplements($exercise, 'SomeInterface'); 26 | $this->assertSame('Exercise: "Some Exercise" should implement interface: "SomeInterface"', $e->getMessage()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/Markdown/Shorthands/Cli/RunTest.php: -------------------------------------------------------------------------------- 1 | __invoke([]); 20 | } 21 | 22 | public function testShorthand(): void 23 | { 24 | $context = new Run('learnyouphp'); 25 | 26 | $result = $context->__invoke(['solution.php']); 27 | self::assertCount(1, $result); 28 | self::assertInstanceOf(Text::class, $result[0]); 29 | self::assertEquals('learnyouphp run solution.php', $result[0]->getContent()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Markdown/Shorthands/Cli/Run.php: -------------------------------------------------------------------------------- 1 | appName = $appName; 21 | } 22 | 23 | /** 24 | * @param array $callArgs 25 | * @return Text[] 26 | */ 27 | public function __invoke(array $callArgs): array 28 | { 29 | if (!isset($callArgs[0])) { 30 | throw new RuntimeException('The solution file must be specified'); 31 | } 32 | 33 | return [ 34 | new Text($this->appName . ' run ' . $callArgs[0]), 35 | ]; 36 | } 37 | 38 | public function getCode(): string 39 | { 40 | return 'run'; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/Markdown/Shorthands/Cli/VerifyTest.php: -------------------------------------------------------------------------------- 1 | __invoke([]); 20 | } 21 | 22 | public function testShorthand(): void 23 | { 24 | $shorthand = new Verify('learnyouphp'); 25 | 26 | $result = $shorthand->__invoke(['solution.php']); 27 | self::assertCount(1, $result); 28 | self::assertInstanceOf(Text::class, $result[0]); 29 | self::assertEquals('learnyouphp verify solution.php', $result[0]->getContent()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Markdown/Shorthands/Cli/Verify.php: -------------------------------------------------------------------------------- 1 | appName = $appName; 21 | } 22 | 23 | /** 24 | * @param array $callArgs 25 | * @return Text[] 26 | */ 27 | public function __invoke(array $callArgs): array 28 | { 29 | if (!isset($callArgs[0])) { 30 | throw new RuntimeException('The solution file must be specified'); 31 | } 32 | 33 | return [ 34 | new Text($this->appName . ' verify ' . $callArgs[0]), 35 | ]; 36 | } 37 | 38 | public function getCode(): string 39 | { 40 | return 'verify'; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/Event/EventTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('super-sweet-event!', $e->getName()); 15 | } 16 | 17 | public function testGetParameters(): void 18 | { 19 | $e = new Event('super-sweet-event-with-cool-params', ['cool' => 'stuff']); 20 | $this->assertEquals('stuff', $e->getParameter('cool')); 21 | $this->assertEquals(['cool' => 'stuff'], $e->getParameters()); 22 | } 23 | 24 | public function testExeceptionIsThrownIfParameterDoesNotExist(): void 25 | { 26 | $this->expectException(InvalidArgumentException::class); 27 | $this->expectExceptionMessage('Parameter: "cool" does not exist'); 28 | $e = new Event('super-sweet-event-with-cool-params'); 29 | $e->getParameter('cool'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/Listener/TearDownListenerTest.php: -------------------------------------------------------------------------------- 1 | cleanupTempDir(); 29 | 30 | self::assertFileDoesNotExist($tempDir . '/some.file'); 31 | self::assertFileDoesNotExist($tempDir . '/some/path/another.file'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/ExerciseCheck/SelfCheck.php: -------------------------------------------------------------------------------- 1 | context = $context; 27 | } 28 | 29 | public static function cli(): self 30 | { 31 | return new self(self::CONTEXT_CLI); 32 | } 33 | 34 | public static function cloud(): self 35 | { 36 | return new self(self::CONTEXT_CLOUD); 37 | } 38 | 39 | public function get(): string 40 | { 41 | return $this->context; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Process/ProcessInput.php: -------------------------------------------------------------------------------- 1 | $args 9 | * @param array $env 10 | */ 11 | public function __construct( 12 | private string $executable, 13 | private array $args, 14 | private string $workingDirectory, 15 | private array $env, 16 | private ?string $input = null, 17 | ) {} 18 | 19 | public function getExecutable(): string 20 | { 21 | return $this->executable; 22 | } 23 | 24 | /** 25 | * @return list 26 | */ 27 | public function getArgs(): array 28 | { 29 | return $this->args; 30 | } 31 | 32 | public function getWorkingDirectory(): string 33 | { 34 | return $this->workingDirectory; 35 | } 36 | 37 | /** 38 | * @return array 39 | */ 40 | public function getEnv(): array 41 | { 42 | return $this->env; 43 | } 44 | 45 | public function getInput(): ?string 46 | { 47 | return $this->input; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Aydin Hassan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

PHP Workshop

2 | 3 |

4 | The core of PHP School, the workshop application. This is the library which runs and compares users solutions to pre-defined known working solutions bundled with each workshop. 5 |

6 | 7 |

8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |

21 | 22 | ---- 23 | 24 |

25 | Check the documentation to learn how to create your own workshop. 26 |

27 | -------------------------------------------------------------------------------- /.github/workflows/php-workshop.yml: -------------------------------------------------------------------------------- 1 | name: PhpWorkshop 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | php: [8.0, 8.1, 8.2, 8.3] 16 | 17 | name: PHP ${{ matrix.php }} 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v2 21 | 22 | - name: Setup PHP 23 | uses: shivammathur/setup-php@v2 24 | with: 25 | php-version: ${{ matrix.php }} 26 | tools: php-cs-fixer,composer:v2 27 | coverage: none 28 | 29 | - name: Install Dependencies 30 | run: composer update --prefer-dist 31 | 32 | - name: Run phpunit tests 33 | run: | 34 | mkdir -p build/logs 35 | vendor/bin/phpunit --coverage-clover ./build/logs/clover.xml 36 | 37 | - name: Run phpcs 38 | run: composer cs 39 | 40 | - name: Run phpstan 41 | run: composer static 42 | 43 | - name: Coverage upload 44 | if: matrix.php == '7.4' 45 | run: bash <(curl -s https://codecov.io/bash) 46 | 47 | -------------------------------------------------------------------------------- /src/Patch/ForceStrictTypes.php: -------------------------------------------------------------------------------- 1 | $statements 14 | * @return array 15 | */ 16 | public function transform(array $statements): array 17 | { 18 | if ($this->isFirstStatementStrictTypesDeclare($statements)) { 19 | return $statements; 20 | } 21 | 22 | $declare = new \PhpParser\Node\Stmt\Declare_([ 23 | new DeclareDeclare( 24 | new \PhpParser\Node\Identifier('strict_types'), 25 | new LNumber(1), 26 | ), 27 | ]); 28 | 29 | return array_merge([$declare], $statements); 30 | } 31 | 32 | /** 33 | * @param array $statements 34 | */ 35 | public function isFirstStatementStrictTypesDeclare(array $statements): bool 36 | { 37 | return isset($statements[0]) && $statements[0] instanceof Declare_; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Listener/CheckExerciseAssignedListener.php: -------------------------------------------------------------------------------- 1 | userState = $userState; 25 | } 26 | 27 | /** 28 | * @param Event $event 29 | */ 30 | public function __invoke(Event $event): void 31 | { 32 | /** @var CommandDefinition $command */ 33 | $command = $event->getParameter('command'); 34 | 35 | if (!in_array($command->getName(), ['verify', 'run', 'print'])) { 36 | return; 37 | } 38 | 39 | if (!$this->userState->isAssignedExercise()) { 40 | throw new RuntimeException('No active exercise. Select one from the menu'); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/Markdown/CurrentContextTest.php: -------------------------------------------------------------------------------- 1 | get()); 23 | 24 | $context = new CurrentContext('cli'); 25 | static::assertEquals('cli', $context->get()); 26 | 27 | $context = CurrentContext::cli(); 28 | static::assertEquals('cli', $context->get()); 29 | 30 | $context = CurrentContext::cloud(); 31 | static::assertEquals('cloud', $context->get()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/Exercise/Scenario/CgiScenarioTest.php: -------------------------------------------------------------------------------- 1 | createMock(RequestInterface::class); 14 | $requestTwo = $this->createMock(RequestInterface::class); 15 | 16 | $scenario = (new CgiScenario()) 17 | ->withFile('file1.txt', 'content1') 18 | ->withFile('file2.txt', 'content2') 19 | ->withExecution($requestOne) 20 | ->withExecution($requestTwo); 21 | 22 | static::assertEquals( 23 | [ 24 | 'file1.txt' => 'content1', 25 | 'file2.txt' => 'content2', 26 | ], 27 | $scenario->getFiles(), 28 | ); 29 | 30 | static::assertEquals( 31 | [ 32 | $requestOne, 33 | $requestTwo, 34 | ], 35 | $scenario->getExecutions(), 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/Result/Cli/CliResultTest.php: -------------------------------------------------------------------------------- 1 | assertSame('CLI Program Runner', $cliResult->getCheckName()); 18 | } 19 | 20 | public function testIsSuccessful(): void 21 | { 22 | $request = new RequestFailure(new ArrayObject(), 'EXPECTED', 'ACTUAL'); 23 | $cliResult = new CliResult([$request]); 24 | 25 | $this->assertFalse($cliResult->isSuccessful()); 26 | 27 | $cliResult = new CliResult([new Success(new ArrayObject())]); 28 | $this->assertTrue($cliResult->isSuccessful()); 29 | 30 | $cliResult->add($request); 31 | $this->assertFalse($cliResult->isSuccessful()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/Event/CgiExerciseRunnerEventTest.php: -------------------------------------------------------------------------------- 1 | 1]); 18 | self::assertSame($context->getExercise(), $event->getExercise()); 19 | self::assertSame($context->getInput(), $event->getInput()); 20 | $this->assertSame($context, $event->getContext()); 21 | $this->assertSame($scenario, $event->getScenario()); 22 | self::assertEquals( 23 | [ 24 | 'exercise' => $context->getExercise(), 25 | 'input' => $context->getInput(), 26 | 'number' => 1, 27 | ], 28 | $event->getParameters(), 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/Event/CliExerciseRunnerEventTest.php: -------------------------------------------------------------------------------- 1 | 1]); 18 | self::assertSame($context->getExercise(), $event->getExercise()); 19 | self::assertSame($context->getInput(), $event->getInput()); 20 | $this->assertSame($context, $event->getContext()); 21 | $this->assertSame($scenario, $event->getScenario()); 22 | self::assertEquals( 23 | [ 24 | 'exercise' => $context->getExercise(), 25 | 'input' => $context->getInput(), 26 | 'number' => 1, 27 | ], 28 | $event->getParameters(), 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/Exercise/Scenario/CliScenarioTest.php: -------------------------------------------------------------------------------- 1 | withFile('file1.txt', 'content1') 15 | ->withFile('file2.txt', 'content2') 16 | ->withExecution(['arg1', 'arg2']) 17 | ->withExecution(['arg3', 'arg4']); 18 | 19 | static::assertEquals( 20 | [ 21 | 'file1.txt' => 'content1', 22 | 'file2.txt' => 'content2', 23 | ], 24 | $scenario->getFiles(), 25 | ); 26 | 27 | static::assertEquals( 28 | [ 29 | ['arg1', 'arg2'], 30 | ['arg3', 'arg4'], 31 | ], 32 | array_map( 33 | fn(Collection $collection) => $collection->getArrayCopy(), 34 | $scenario->getExecutions(), 35 | ), 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Exception/CheckNotApplicableException.php: -------------------------------------------------------------------------------- 1 | getName(), 30 | $exercise->getName(), 31 | $exercise->getType(), 32 | ), 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/ExerciseRunner/Factory/ExerciseRunnerFactoryInterface.php: -------------------------------------------------------------------------------- 1 | request = $request; 30 | } 31 | 32 | /** 33 | * Get the name of the check that this result was produced from. 34 | * 35 | * @return string 36 | */ 37 | public function getCheckName(): string 38 | { 39 | return $this->name; 40 | } 41 | 42 | /** 43 | * Get the request for this success. 44 | * 45 | * @return RequestInterface 46 | */ 47 | public function getRequest(): RequestInterface 48 | { 49 | return $this->request; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Result/Cli/Success.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private $args; 18 | 19 | /** 20 | * @var string 21 | */ 22 | private $name = 'CLI Program Runner'; 23 | 24 | /** 25 | * @param ArrayObject $args The arguments for this success. 26 | */ 27 | public function __construct(ArrayObject $args) 28 | { 29 | $this->args = $args; 30 | } 31 | 32 | /** 33 | * Get the name of the check that this result was produced from. 34 | * 35 | * @return string 36 | */ 37 | public function getCheckName(): string 38 | { 39 | return $this->name; 40 | } 41 | 42 | /** 43 | * Get the arguments for this success. 44 | * 45 | * @return ArrayObject 46 | */ 47 | public function getArgs(): ArrayObject 48 | { 49 | return $this->args; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/ResultRenderer/FileComparisonFailureRendererTest.php: -------------------------------------------------------------------------------- 1 | createMock(CheckInterface::class), 17 | 'some-file.text', 18 | 'EXPECTED OUTPUT', 19 | 'ACTUAL OUTPUT', 20 | ); 21 | $renderer = new FileComparisonFailureRenderer($failure); 22 | 23 | $expected = " \e[33m\e[1mYOUR OUTPUT FOR: \e[0m\e[0m\e[32m\e[1msome-file.text\e[0m\e[0m\n"; 24 | $expected .= " \e[31m\"ACTUAL OUTPUT\"\e[0m\n\n"; 25 | $expected .= " \e[33m\e[1mEXPECTED OUTPUT FOR: \e[0m\e[0m\e[32m\e[1msome-file.text\e[0m\e[0m\n"; 26 | $expected .= " \e[32m\"EXPECTED OUTPUT\"\e[0m\n"; 27 | 28 | $this->assertEquals($expected, $renderer->render($this->getRenderer())); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Result/Success.php: -------------------------------------------------------------------------------- 1 | name = $name; 25 | } 26 | 27 | /** 28 | * Static constructor to create from an instance of `PhpSchool\PhpWorkshop\Check\CheckInterface`. 29 | * 30 | * @param CheckInterface $check The check instance. 31 | * @return self The result. 32 | */ 33 | public static function fromCheck(CheckInterface $check): self 34 | { 35 | return new self($check->getName()); 36 | } 37 | 38 | /** 39 | * Get the name of the check that this result was produced from. 40 | * 41 | * @return string 42 | */ 43 | public function getCheckName(): string 44 | { 45 | return $this->name; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/Result/Cgi/CgiResultTest.php: -------------------------------------------------------------------------------- 1 | createMock(RequestInterface::class), '', '', [], []); 16 | $cgiResult = new CgiResult([$request]); 17 | $this->assertSame('CGI Program Runner', $cgiResult->getCheckName()); 18 | } 19 | 20 | public function testIsSuccessful(): void 21 | { 22 | $request = new RequestFailure($this->createMock(RequestInterface::class), '', '', [], []); 23 | $cgiResult = new CgiResult([$request]); 24 | 25 | $this->assertFalse($cgiResult->isSuccessful()); 26 | 27 | $cgiResult = new CgiResult([new Success($this->createMock(RequestInterface::class))]); 28 | $this->assertTrue($cgiResult->isSuccessful()); 29 | 30 | $cgiResult->add($request); 31 | $this->assertFalse($cgiResult->isSuccessful()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/res/exercise-renderer/test-render-success.txt: -------------------------------------------------------------------------------- 1 | 2 | *** RESULTS *** 3 | 4 |   5 |  ✔ Check: Success 1!  6 |   7 | 8 |   9 |  ✔ Check: Success 2!  10 |   11 | 12 | ──────────────────────────────────────────────────────────────────────────────────────────────────── 13 | 14 |   15 |  PASS!  16 |   17 | 18 | 19 | You have 1 challenges left. 20 | Type "app" and hit enter to show the menu. 21 | 22 | -------------------------------------------------------------------------------- /src/Markdown/Renderer/ContextSpecificRenderer.php: -------------------------------------------------------------------------------- 1 | currentContext = $currentContext; 23 | } 24 | 25 | public function render(AbstractBlock $block, ElementRendererInterface $renderer, bool $inTightList = false): string 26 | { 27 | if (!$block instanceof ContextSpecificBlock) { 28 | return ''; 29 | } 30 | 31 | if ($this->currentContext->get() !== $block->getType()) { 32 | return ''; 33 | } 34 | 35 | /** @var iterable $children */ 36 | $children = $block->children(); 37 | return $renderer->renderBlocks($children); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Logger/ConsoleLogger.php: -------------------------------------------------------------------------------- 1 | output = $output; 27 | $this->color = $color; 28 | } 29 | 30 | /** 31 | * @param string $level 32 | */ 33 | public function log($level, $message, array $context = []): void 34 | { 35 | $parts = [ 36 | sprintf( 37 | '%s - %s - %s', 38 | $this->color->fg('yellow', (new \DateTime())->format('H:i:s')), 39 | $this->color->bg('red', strtoupper($level)), 40 | $this->color->fg('red', $message), 41 | ), 42 | json_encode($context, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), 43 | ]; 44 | 45 | $this->output->writeLine(implode("\n", $parts)); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Markdown/Shorthands/Documentation.php: -------------------------------------------------------------------------------- 1 | filePath = $filePath; 20 | } 21 | 22 | /** 23 | * @param string $level 24 | */ 25 | public function log($level, $message, array $context = []): void 26 | { 27 | if (!file_exists(dirname($this->filePath))) { 28 | if (!mkdir($concurrentDirectory = dirname($this->filePath), 0777, true) && !is_dir($concurrentDirectory)) { 29 | throw new \RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory)); 30 | } 31 | } 32 | 33 | file_put_contents( 34 | $this->filePath, 35 | sprintf( 36 | "Time: %s, Level: %s, Message: %s, Context: %s\n\n", 37 | (new \DateTime())->format('d-m-y H:i:s'), 38 | $level, 39 | $message, 40 | json_encode($context), 41 | ), 42 | FILE_APPEND, 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/MenuItem/ResetProgress.php: -------------------------------------------------------------------------------- 1 | userStateSerializer = $userStateSerializer; 27 | } 28 | 29 | /** 30 | * @param CliMenu $menu 31 | */ 32 | public function __invoke(CliMenu $menu): void 33 | { 34 | $this->userStateSerializer->serialize(new UserState()); 35 | 36 | $parent = $menu->getParent(); 37 | 38 | if (!$parent) { 39 | return; 40 | } 41 | 42 | $items = $parent->getItems(); 43 | 44 | foreach ($items as $item) { 45 | $item->hideItemExtra(); 46 | } 47 | 48 | $confirm = $menu->confirm('Status Reset!'); 49 | $confirm->getStyle()->setBg('magenta')->setFg('black'); 50 | $confirm->display('OK'); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/res/app-help-expected.txt: -------------------------------------------------------------------------------- 1 | Usage 2 | 3 | learnyouphp 4 | Show a menu to interactively select a workshop. 5 | learnyouphp print 6 | Print the instructions for the currently selected workshop. 7 | learnyouphp verify program.php 8 | Verify your program against the expected output. 9 | learnyouphp help 10 | Show this help. 11 | learnyouphp credits 12 | Show the people who made this happen. 13 | 14 | Having trouble with a PHPSchool exercise? 15 | 16 | A team of expert helper elves is eagerly waiting to assist you in 17 | mastering the basics of PHP, simply go to: 18 | https://github.com/php-school/discussions 19 | and add a New Issue and let us know what you're having trouble 20 | with. There are no dumb questions! 21 | 22 | If you're looking for general help with PHP, the #php 23 | channel on Freenode IRC is usually a great place to find someone 24 | willing to help. There is also the PHP StackOverflow Chat: 25 | https://chat.stackoverflow.com/rooms/11/php 26 | 27 | Found a bug with PHPSchool or just want to contribute? 28 | The official repository for PHPSchool is: 29 | https://github.com/php-school/php-workshop 30 | Feel free to file a bug report or (preferably) a pull request. 31 | 32 | -------------------------------------------------------------------------------- /test/ResultRenderer/FunctionRequirementsFailureRendererTest.php: -------------------------------------------------------------------------------- 1 | createMock(CheckInterface::class), 15 | [['function' => 'file', 'line' => 3], ['function' => 'explode', 'line' => 5]], 16 | ['implode'], 17 | ); 18 | $renderer = new FunctionRequirementsFailureRenderer($failure); 19 | 20 | $expected = " Some functions were used which should not be used in this exercise\n"; 21 | $expected .= " file on line 3\n"; 22 | $expected .= " explode on line 5\n"; 23 | 24 | $expected .= ' Some function requirements were missing. You should use the functions'; 25 | $expected .= "\n"; 26 | $expected .= " implode\n"; 27 | 28 | $this->assertEquals($expected, $renderer->render($this->getRenderer())); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/Markdown/Shorthands/ContextTest.php: -------------------------------------------------------------------------------- 1 | __invoke([])); 19 | } 20 | 21 | public function testIgnoresCallsWithNoContent(): void 22 | { 23 | $currentContext = CurrentContext::cli(); 24 | 25 | $context = new Context($currentContext); 26 | 27 | self::assertEquals([], $context->__invoke(['cli'])); 28 | } 29 | 30 | public function testWithContent(): void 31 | { 32 | $currentContext = CurrentContext::cli(); 33 | 34 | $context = new Context($currentContext); 35 | 36 | $result = $context->__invoke(['cli', 'some content']); 37 | self::assertCount(1, $result); 38 | self::assertInstanceOf(Text::class, $result[0]); 39 | self::assertEquals('some content', $result[0]->getContent()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Exception/MissingArgumentException.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private $missingArguments; 18 | 19 | /** 20 | * Create the exception, requires the command name and missing arguments. 21 | * 22 | * @param string $commandName The command name. 23 | * @param array $missingArguments An array of missing arguments. 24 | */ 25 | public function __construct(string $commandName, array $missingArguments) 26 | { 27 | $this->missingArguments = $missingArguments; 28 | parent::__construct( 29 | sprintf( 30 | 'Command: "%s" is missing the following arguments: "%s"', 31 | $commandName, 32 | implode('", "', $missingArguments), 33 | ), 34 | ); 35 | } 36 | 37 | /** 38 | * Retrieve the list of missing arguments. 39 | * 40 | * @return array 41 | */ 42 | public function getMissingArguments(): array 43 | { 44 | return $this->missingArguments; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/Utils/SystemTest.php: -------------------------------------------------------------------------------- 1 | expectException(RuntimeException::class); 16 | $this->expectExceptionMessage('Failed to get realpath of "non_existing_file.txt"'); 17 | 18 | System::realpath('non_existing_file.txt'); 19 | } 20 | 21 | public function testRealpathReturnsFullPath(): void 22 | { 23 | self::assertSame(realpath(__DIR__), System::realpath(__DIR__)); 24 | } 25 | 26 | public function testTempDir(): void 27 | { 28 | self::assertSame(realpath(sys_get_temp_dir()) . '/php-school', System::tempDir()); 29 | } 30 | 31 | public function testTempDirWithPath(): void 32 | { 33 | $expect = sprintf('%s/php-school/%s', realpath(sys_get_temp_dir()), 'test'); 34 | self::assertSame($expect, System::tempDir('test')); 35 | } 36 | 37 | public function testRandomTempDir(): void 38 | { 39 | self::assertTrue(str_starts_with(System::randomTempDir(), realpath(sys_get_temp_dir()) . '/php-school')); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Markdown/Block/ContextSpecificBlock.php: -------------------------------------------------------------------------------- 1 | type = $type; 26 | } 27 | 28 | public function getType(): string 29 | { 30 | return $this->type; 31 | } 32 | 33 | public function canContain(AbstractBlock $block): bool 34 | { 35 | return true; 36 | } 37 | 38 | public function isCode(): bool 39 | { 40 | return false; 41 | } 42 | 43 | public function matchesNextLine(Cursor $cursor): bool 44 | { 45 | $content = $cursor->match(ContextSpecificBlockParser::getEndBlockRegex($this->type)); 46 | return $content === null; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/ComposerUtil/LockFileParserTest.php: -------------------------------------------------------------------------------- 1 | assertEquals([ 15 | ['name' => 'danielstjules/stringy', 'version' => '2.1.0'], 16 | ['name' => 'klein/klein', 'version' => 'v2.1.0'], 17 | ], $locker->getInstalledPackages()); 18 | } 19 | 20 | public function testHasPackage(): void 21 | { 22 | $locker = new LockFileParser(__DIR__ . '/../res/composer.lock'); 23 | $this->assertTrue($locker->hasInstalledPackage('danielstjules/stringy')); 24 | $this->assertTrue($locker->hasInstalledPackage('klein/klein')); 25 | $this->assertFalse($locker->hasInstalledPackage('not-a-package')); 26 | } 27 | 28 | public function testExceptionIsThrownIfFileNotExists(): void 29 | { 30 | $this->expectException(InvalidArgumentException::class); 31 | $this->expectExceptionMessage('Lock File: "not-a-file" does not exist'); 32 | new LockFileParser('not-a-file'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/Patch/ForceStrictTypesTest.php: -------------------------------------------------------------------------------- 1 | create(ParserFactory::PREFER_PHP7); 15 | $ast = $parser->parse("transform($ast); 19 | 20 | self::assertSame( 21 | "declare (strict_types=1);\necho 'Hello World';", 22 | (new Standard())->prettyPrint($ast), 23 | ); 24 | } 25 | 26 | public function testStrictTypesDeclareIsNotAppendedIfItAlreadyExists(): void 27 | { 28 | $parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7); 29 | $ast = $parser->parse("transform($ast); 33 | 34 | self::assertSame( 35 | "declare (strict_types=1);\necho 'Hello World';", 36 | (new Standard())->prettyPrint($ast), 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/ExerciseCheck/DatabaseExerciseCheck.php: -------------------------------------------------------------------------------- 1 | $parameters 21 | */ 22 | public function __construct(string $name, ExecutionContext $context, array $parameters = []) 23 | { 24 | $this->context = $context; 25 | 26 | $parameters['input'] = $context->getInput(); 27 | $parameters['exercise'] = $context->getExercise(); 28 | parent::__construct($name, $parameters); 29 | } 30 | 31 | public function getContext(): ExecutionContext 32 | { 33 | return $this->context; 34 | } 35 | 36 | /** 37 | * @return Input 38 | */ 39 | public function getInput(): Input 40 | { 41 | return $this->context->getInput(); 42 | } 43 | 44 | /** 45 | * @return ExerciseInterface 46 | */ 47 | public function getExercise(): ExerciseInterface 48 | { 49 | return $this->context->getExercise(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Output/BufferedOutput.php: -------------------------------------------------------------------------------- 1 | buffer .= $content; 27 | } 28 | 29 | public function writeLines(array $lines): void 30 | { 31 | foreach ($lines as $line) { 32 | $this->writeLine($line); 33 | } 34 | } 35 | 36 | public function writeLine(string $line): void 37 | { 38 | $this->buffer .= $line . "\n"; 39 | } 40 | 41 | public function emptyLine(): void 42 | { 43 | $this->buffer .= "\n"; 44 | } 45 | 46 | public function lineBreak(): void 47 | { 48 | // noop 49 | } 50 | 51 | public function writeTitle(string $title): void 52 | { 53 | // noop 54 | } 55 | 56 | public function fetch(bool $clear = false): string 57 | { 58 | $buffer = $this->buffer; 59 | 60 | if ($clear) { 61 | $this->buffer = ''; 62 | } 63 | 64 | return $buffer; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/MarkdownRenderer.php: -------------------------------------------------------------------------------- 1 | docParser = $docParser; 36 | $this->cliRenderer = $cliRenderer; 37 | } 38 | 39 | /** 40 | * Expects a string of markdown and returns a string which has been formatted for 41 | * displaying on the console. 42 | * 43 | * @param string $markdown 44 | * @return string 45 | */ 46 | public function render(string $markdown): string 47 | { 48 | $ast = $this->docParser->parse($markdown); 49 | return $this->cliRenderer->renderBlock($ast); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Process/HostProcessFactory.php: -------------------------------------------------------------------------------- 1 | executableFinder = $executableFinder ?? new ExecutableFinder(); 17 | } 18 | 19 | 20 | public function create(ProcessInput $processInput): Process 21 | { 22 | $executablePath = $this->executableFinder->find($processInput->getExecutable()); 23 | 24 | if ($executablePath === null) { 25 | throw ProcessNotFoundException::fromExecutable($processInput->getExecutable()); 26 | } 27 | 28 | return new Process( 29 | [$executablePath, ...$processInput->getArgs()], 30 | $processInput->getWorkingDirectory(), 31 | $this->getDefaultEnv() + $processInput->getEnv(), 32 | $processInput->getInput(), 33 | 10, 34 | ); 35 | } 36 | 37 | /** 38 | * @return array 39 | */ 40 | private function getDefaultEnv(): array 41 | { 42 | $env = array_map(fn() => false, $_ENV); 43 | $env + array_map(fn() => false, $_SERVER); 44 | 45 | return $env; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/BaseTest.php: -------------------------------------------------------------------------------- 1 | tempDirectory) { 19 | $this->tempDirectory = System::tempDir($this->getName()); 20 | mkdir($this->tempDirectory, 0777, true); 21 | } 22 | 23 | return $this->tempDirectory; 24 | } 25 | 26 | public function getTemporaryFile(string $filename, string $content = null): string 27 | { 28 | $file = Path::join($this->getTemporaryDirectory(), $filename); 29 | 30 | if (file_exists($file)) { 31 | return $file; 32 | } 33 | 34 | if (!file_exists(dirname($file))) { 35 | mkdir(dirname($file), 0777, true); 36 | } 37 | 38 | $content !== null 39 | ? file_put_contents($file, $content) 40 | : touch($file); 41 | 42 | return $file; 43 | } 44 | 45 | public function tearDown(): void 46 | { 47 | if (file_exists(System::tempDir($this->getName()))) { 48 | (new Filesystem())->remove(System::tempDir($this->getName())); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/res/exercise-renderer/test-render-success-and-failure.txt: -------------------------------------------------------------------------------- 1 | 2 | *** RESULTS *** 3 | 4 |   5 |  ✔ Check: Success 1!  6 |   7 | 8 |   9 |  ✗ Check: Check 1  10 |   11 | 12 | Failure 13 |   14 |  ✗ Check: Check 2  15 |   16 | 17 | Failure 18 | ──────────────────────────────────────────────────────────────────────────────────────────────────── 19 | 20 |   21 |  Your solution was unsuccessful!  22 |   23 | 24 | Your solution to didn't pass. Try again! 25 | 26 | 27 | -------------------------------------------------------------------------------- /test/Result/Cli/RequestFailureTest.php: -------------------------------------------------------------------------------- 1 | assertSame('Request Failure', $failure->getCheckName()); 16 | $this->assertSame($args, $failure->getArgs()); 17 | } 18 | 19 | public function testGetters(): void 20 | { 21 | $args = new ArrayObject(); 22 | $failure = new RequestFailure($args, 'Expected Output', 'Actual Output'); 23 | $this->assertEquals('Expected Output', $failure->getExpectedOutput()); 24 | $this->assertEquals('Actual Output', $failure->getActualOutput()); 25 | $this->assertSame($args, $failure->getArgs()); 26 | } 27 | 28 | public function testFailureFromArgsAndOutput(): void 29 | { 30 | $args = new ArrayObject(); 31 | $failure = RequestFailure::fromArgsAndOutput($args, 'Expected Output', 'Actual Output'); 32 | $this->assertEquals('Expected Output', $failure->getExpectedOutput()); 33 | $this->assertEquals('Actual Output', $failure->getActualOutput()); 34 | $this->assertSame($args, $failure->getArgs()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/Asset/PatchableExercise.php: -------------------------------------------------------------------------------- 1 | 18 | * @method static self CLI() 19 | * @method static self CGI() 20 | * @method static self CUSTOM() 21 | */ 22 | class ExerciseType extends Enum 23 | { 24 | public const CLI = 'CLI'; 25 | public const CGI = 'CGI'; 26 | public const CUSTOM = 'CUSTOM'; 27 | 28 | /** 29 | * Map of exercise types to the required interfaces exercises of that particular 30 | * type should implement. 31 | * 32 | * @var array 33 | */ 34 | private static $exerciseTypeToExerciseInterfaceMap = [ 35 | self::CLI => CliExercise::class, 36 | self::CGI => CgiExercise::class, 37 | self::CUSTOM => CustomVerifyingExercise::class, 38 | ]; 39 | 40 | /** 41 | * Get the FQCN of the interface this exercise should implement for this 42 | * exercise type. 43 | * 44 | * @return class-string 45 | */ 46 | public function getExerciseInterface(): string 47 | { 48 | return self::$exerciseTypeToExerciseInterfaceMap[$this->getKey()]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/Asset/MockEventDispatcher.php: -------------------------------------------------------------------------------- 1 | dispatches[$event->getName()]) 23 | ? $this->dispatches[$event->getName()]++ 24 | : $this->dispatches[$event->getName()] = 1; 25 | 26 | return $event; 27 | } 28 | 29 | public function listen($eventNames, callable $callback): void 30 | { 31 | if (!is_array($eventNames)) { 32 | $eventNames = [$eventNames]; 33 | } 34 | 35 | foreach ($eventNames as $eventName) { 36 | isset($this->listeners[$eventName]) 37 | ? $this->listeners[$eventName][] = $callback 38 | : $this->listeners[$eventName] = [$callback]; 39 | } 40 | } 41 | 42 | public function getEventDispatchCount(string $eventName): int 43 | { 44 | return $this->dispatches[$eventName] ?? 0; 45 | } 46 | 47 | public function getEventListeners(string $eventName): array 48 | { 49 | return $this->listeners[$eventName] ?? []; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/Logger/ConsoleLoggerTest.php: -------------------------------------------------------------------------------- 1 | container->set('phpschoolGlobalDir', $this->getTemporaryDirectory()); 20 | $this->container->set('appName', 'my-workshop'); 21 | $this->container->set('basePath', __DIR__ . '/../'); 22 | $this->container->set('debugMode', true); 23 | } 24 | 25 | public function testConsoleLoggerIsCreatedIfDebugModeEnable(): void 26 | { 27 | $this->assertInstanceOf(ConsoleLogger::class, $this->container->get(LoggerInterface::class)); 28 | } 29 | 30 | public function testLoggerWithContext(): void 31 | { 32 | $logger = $this->container->get(LoggerInterface::class); 33 | $logger->critical('Failed to copy file', ['exercise' => 'my-exercise']); 34 | 35 | $out = StringUtil::stripAnsiEscapeSequence($this->getActualOutputForAssertion()); 36 | 37 | $match = '/\d{2}\:\d{2}\:\d{2} - CRITICAL - Failed to copy file\n{\n "exercise": "my-exercise"\n}/'; 38 | $this->assertMatchesRegularExpression($match, $out); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/Asset/ProvidesSolutionExercise.php: -------------------------------------------------------------------------------- 1 | name = $name; 29 | $this->optional = $optional; 30 | } 31 | 32 | /** 33 | * @param string $name 34 | * @return self 35 | */ 36 | public static function optional(string $name): self 37 | { 38 | return new self($name, true); 39 | } 40 | 41 | /** 42 | * @param string $name 43 | * @return self 44 | */ 45 | public static function required(string $name): self 46 | { 47 | return new self($name); 48 | } 49 | 50 | /** 51 | * @return string 52 | */ 53 | public function getName(): string 54 | { 55 | return $this->name; 56 | } 57 | 58 | /** 59 | * @return bool 60 | */ 61 | public function isRequired(): bool 62 | { 63 | return !$this->isOptional(); 64 | } 65 | 66 | /** 67 | * @return bool 68 | */ 69 | public function isOptional(): bool 70 | { 71 | return $this->optional; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /test/Exception/CheckNotApplicableExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('nope', $e->getMessage()); 17 | } 18 | 19 | public function testFromCheckAndExerciseConstructor(): void 20 | { 21 | $exercise = $this->createMock(ExerciseInterface::class); 22 | $exercise 23 | ->expects($this->once()) 24 | ->method('getName') 25 | ->willReturn('Some Exercise'); 26 | 27 | $exercise 28 | ->expects($this->once()) 29 | ->method('getType') 30 | ->willReturn(ExerciseType::CLI()); 31 | 32 | $check = $this->createMock(CheckInterface::class); 33 | $check 34 | ->expects($this->once()) 35 | ->method('getName') 36 | ->willReturn('Some Check'); 37 | 38 | 39 | $e = CheckNotApplicableException::fromCheckAndExercise($check, $exercise); 40 | 41 | $msg = 'Check: "Some Check" cannot process exercise: "Some Exercise" with type: "CLI"'; 42 | $this->assertSame($msg, $e->getMessage()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /test/Input/InputTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('app', $input->getAppName()); 15 | } 16 | 17 | public function testGetArgument(): void 18 | { 19 | $input = new Input('app', ['arg1' => 'some-value']); 20 | $this->assertEquals('some-value', $input->getArgument('arg1')); 21 | } 22 | 23 | public function testGetArgumentThrowsExceptionIfArgumentNotExist(): void 24 | { 25 | $this->expectException(InvalidArgumentException::class); 26 | $this->expectExceptionMessage('Argument with name: "arg1" does not exist'); 27 | 28 | $input = new Input('app'); 29 | $input->getArgument('arg1'); 30 | } 31 | 32 | public function testHasArgument(): void 33 | { 34 | $input = new Input('app', ['arg1' => 'some-value']); 35 | $this->assertTrue($input->hasArgument('arg1')); 36 | $this->assertFalse($input->hasArgument('arg2')); 37 | } 38 | 39 | public function testSetArgument(): void 40 | { 41 | $input = new Input('app'); 42 | $this->assertFalse($input->hasArgument('arg1')); 43 | $input->setArgument('arg1', 'some-value'); 44 | $this->assertEquals('some-value', $input->getArgument('arg1')); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/Command/PrintCommandTest.php: -------------------------------------------------------------------------------- 1 | setProblem($file); 22 | 23 | $repo = new ExerciseRepository([$exercise]); 24 | 25 | $state = new UserState(); 26 | $state->setCurrentExercise('my-exercise'); 27 | 28 | $output = $this->createMock(OutputInterface::class); 29 | $renderer = $this->createMock(MarkdownRenderer::class); 30 | 31 | $renderer 32 | ->expects($this->once()) 33 | ->method('render') 34 | ->with('### Exercise 1') 35 | ->willReturn('### Exercise 1'); 36 | 37 | $output 38 | ->expects($this->once()) 39 | ->method('write') 40 | ->with('### Exercise 1'); 41 | 42 | $command = new PrintCommand('phpschool', $repo, $state, $renderer, $output); 43 | $command->__invoke(); 44 | 45 | unlink($file); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/Exception/CodeExecutionExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('nope', $e->getMessage()); 15 | } 16 | 17 | public function testFromProcessUsesErrorOutputIfNotEmpty(): void 18 | { 19 | $process = $this->createMock(Process::class); 20 | 21 | $process 22 | ->expects($this->once()) 23 | ->method('getErrorOutput') 24 | ->willReturn('Error Output'); 25 | 26 | $e = CodeExecutionException::fromProcess($process); 27 | $this->assertEquals('PHP Code failed to execute. Error: "Error Output"', $e->getMessage()); 28 | } 29 | 30 | public function testFromProcessUsesStdOutputIfErrorOutputEmpty(): void 31 | { 32 | $process = $this->createMock(Process::class); 33 | $process 34 | ->expects($this->once()) 35 | ->method('getErrorOutput') 36 | ->willReturn(''); 37 | 38 | $process 39 | ->expects($this->once()) 40 | ->method('getOutput') 41 | ->willReturn('Std Output'); 42 | 43 | $e = CodeExecutionException::fromProcess($process); 44 | $this->assertEquals('PHP Code failed to execute. Error: "Std Output"', $e->getMessage()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Patch/WrapInTryCatch.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | private $statements; 24 | 25 | /** 26 | * @param string $exceptionClass 27 | * @param array|null $statements 28 | */ 29 | public function __construct(string $exceptionClass = \Exception::class, array $statements = null) 30 | { 31 | $this->exceptionClass = $exceptionClass; 32 | $this->statements = $statements ?: [ 33 | new Echo_([ 34 | new MethodCall(new Variable('e'), 'getMessage'), 35 | ]), 36 | ]; 37 | } 38 | 39 | /** 40 | * @param array $statements 41 | * @return array 42 | */ 43 | public function transform(array $statements): array 44 | { 45 | return [ 46 | new TryCatch( 47 | $statements, 48 | [ 49 | new Catch_( 50 | [new Name($this->exceptionClass)], 51 | new Variable('e'), 52 | $this->statements, 53 | ), 54 | ], 55 | ), 56 | ]; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Markdown/Shorthands/Cli/AppName.php: -------------------------------------------------------------------------------- 1 | appName = $appName; 23 | } 24 | 25 | public function __invoke(array $callArgs): array 26 | { 27 | $wrapped = isset($callArgs[0]); 28 | 29 | if (false === $wrapped) { 30 | return [new Text($this->appName)]; 31 | } 32 | 33 | switch ($callArgs[0]) { 34 | case '`': 35 | return [new Code($this->appName)]; 36 | case '*': 37 | $text = new Text($this->appName); 38 | $container = new Strong(); 39 | $container->appendChild($text); 40 | return [$container]; 41 | case '_': 42 | $text = new Text($this->appName); 43 | $container = new Emphasis(); 44 | $container->appendChild($text); 45 | return [$container]; 46 | } 47 | 48 | return []; 49 | } 50 | 51 | public function getCode(): string 52 | { 53 | return 'appname'; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/Asset/ExerciseWithInitialCode.php: -------------------------------------------------------------------------------- 1 | getCharacter() !== '{') { 35 | return false; 36 | } 37 | 38 | $tagged = $cursor->match(self::getParserRegex()); 39 | if ($tagged === null) { 40 | return false; 41 | } 42 | 43 | $type = trim(str_replace(['{', '}'], '', $tagged)); 44 | 45 | $context->addBlock(new ContextSpecificBlock($type)); 46 | 47 | return true; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Utils/RequestRenderer.php: -------------------------------------------------------------------------------- 1 | getUri()); 24 | $return .= sprintf("METHOD: %s\n", $request->getMethod()); 25 | 26 | if ($request->getHeaders()) { 27 | $return .= 'HEADERS:'; 28 | } 29 | 30 | $indent = false; 31 | foreach ($request->getHeaders() as $name => $values) { 32 | if ($indent) { 33 | $return .= str_repeat(' ', 8); 34 | } 35 | 36 | $return .= sprintf(" %s: %s\n", $name, implode(', ', $values)); 37 | $indent = true; 38 | } 39 | 40 | if ($body = (string) $request->getBody()) { 41 | $return .= 'BODY: '; 42 | 43 | switch ($request->getHeaderLine('Content-Type')) { 44 | case 'application/json': 45 | $return .= json_encode(json_decode($body, true), JSON_PRETTY_PRINT); 46 | break; 47 | default: 48 | $return .= $body; 49 | break; 50 | } 51 | 52 | $return .= "\n"; 53 | } 54 | 55 | return $return; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Markdown/ProblemFileExtension.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | private $shorthandExpanders; 26 | 27 | /** 28 | * @param array $shorthandExpanders 29 | */ 30 | public function __construct( 31 | ContextSpecificRenderer $contextSpecificRenderer, 32 | array $shorthandExpanders, 33 | ) { 34 | $this->contextSpecificRenderer = $contextSpecificRenderer; 35 | $this->shorthandExpanders = $shorthandExpanders; 36 | } 37 | 38 | public function register(ConfigurableEnvironmentInterface $environment): void 39 | { 40 | $environment 41 | ->addBlockParser(new ContextSpecificBlockParser()) 42 | ->addInlineParser(new HandleBarParser($this->shorthandExpanders)) 43 | ->addBlockRenderer(ContextSpecificBlock::class, $this->contextSpecificRenderer); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/ResultRenderer/ComposerFailureRenderer.php: -------------------------------------------------------------------------------- 1 | result = $result; 25 | } 26 | 27 | /** 28 | * Print a list of the missing components and packages. 29 | * 30 | * @param ResultsRenderer $renderer 31 | * @return string 32 | */ 33 | public function render(ResultsRenderer $renderer): string 34 | { 35 | if ($this->result->isMissingComponent()) { 36 | /** @var string $component */ 37 | $component = $this->result->getMissingComponent(); 38 | 39 | $type = str_contains($component, '.') ? 'file' : 'folder'; 40 | 41 | return $renderer->center("No $component $type found") . "\n"; 42 | } 43 | 44 | if ($this->result->isMissingPackages()) { 45 | $missingPackages = $this->result->getMissingPackages(); 46 | 47 | return $renderer->center(sprintf( 48 | "Lockfile doesn't include the following packages at any version: \"%s\"\n", 49 | implode('", "', $missingPackages), 50 | )); 51 | } 52 | 53 | return ''; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Listener/LazyContainerListener.php: -------------------------------------------------------------------------------- 1 | container = $container; 24 | $this->listener = $listener; 25 | } 26 | 27 | /** 28 | * @param mixed ...$args 29 | */ 30 | public function __invoke(...$args): void 31 | { 32 | /** @var object $service */ 33 | $service = $this->container->get($this->listener->getService()); 34 | 35 | if (!method_exists($service, $this->listener->getMethod())) { 36 | throw new InvalidArgumentException( 37 | sprintf('Method "%s" does not exist on "%s"', $this->listener->getMethod(), get_class($service)), 38 | ); 39 | } 40 | 41 | $service->{$this->listener->getMethod()}(...$args); 42 | } 43 | 44 | /** 45 | * @return callable 46 | */ 47 | public function getWrapped(): callable 48 | { 49 | /** @var callable $listener */ 50 | $listener = [ 51 | $this->container->get($this->listener->getService()), 52 | $this->listener->getMethod(), 53 | ]; 54 | 55 | return $listener; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/res/exercise-renderer/test-render-success-with-solution.txt: -------------------------------------------------------------------------------- 1 | 2 | *** RESULTS *** 3 | 4 |   5 |  ✔ Check: Success 1!  6 |   7 | 8 |   9 |  ✔ Check: Success 2!  10 |   11 | 12 | ──────────────────────────────────────────────────────────────────────────────────────────────────── 13 | 14 |   15 |  PASS!  16 |   17 | 18 | Here's the official solution in case you want to compare notes: 19 | 20 | ──────────────────────────────────────────────────────────────────────────────────────────────────── 21 | some-file 22 | 23 | FILE CONTENTS 24 | ──────────────────────────────────────────────────────────────────────────────────────────────────── 25 | 26 | You have 1 challenges left. 27 | Type "app" and hit enter to show the menu. 28 | 29 | -------------------------------------------------------------------------------- /test/Asset/ComposerExercise.php: -------------------------------------------------------------------------------- 1 | $lines 41 | */ 42 | public function writeLines(array $lines): void; 43 | 44 | /** 45 | * Write a string terminated with a newline. 46 | * 47 | * @param string $line 48 | */ 49 | public function writeLine(string $line): void; 50 | 51 | /** 52 | * Write an empty line. 53 | */ 54 | public function emptyLine(): void; 55 | 56 | /** 57 | * Write a line break. 58 | */ 59 | public function lineBreak(): void; 60 | 61 | /** 62 | * Write a title section. Should be decorated in a way which makes 63 | * the title stand out. 64 | * 65 | * @param string $title 66 | */ 67 | public function writeTitle(string $title): void; 68 | } 69 | -------------------------------------------------------------------------------- /test/Markdown/Block/ContextSpecificBlockTest.php: -------------------------------------------------------------------------------- 1 | getType()); 25 | } 26 | 27 | public function testConfig(): void 28 | { 29 | $block = new ContextSpecificBlock('cli'); 30 | 31 | static::assertTrue($block->canContain($this->getMockForAbstractClass(AbstractBlock::class))); 32 | static::assertFalse($block->isCode()); 33 | } 34 | 35 | public function testMatchesNextLine(): void 36 | { 37 | $block = new ContextSpecificBlock('cli'); 38 | 39 | static::assertTrue($block->matchesNextLine(new Cursor('Some line'))); 40 | static::assertTrue($block->matchesNextLine(new Cursor('* Item 1'))); 41 | static::assertTrue($block->matchesNextLine(new Cursor('* Item 2'))); 42 | 43 | static::assertFalse($block->matchesNextLine(new Cursor('{{ cli }}'))); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/ExerciseRunner/Factory/CustomVerifyingRunnerFactory.php: -------------------------------------------------------------------------------- 1 | getType()->getValue() === self::$type; 33 | } 34 | 35 | /** 36 | * Add any extra required arguments to the command. 37 | * 38 | * @param CommandDefinition $commandDefinition 39 | */ 40 | public function configureInput(CommandDefinition $commandDefinition): void {} 41 | 42 | /** 43 | * Create and return an instance of the runner. 44 | * 45 | * @param ExerciseInterface&CustomVerifyingExercise $exercise 46 | * @return ExerciseRunnerInterface 47 | */ 48 | public function create(ExerciseInterface $exercise): ExerciseRunnerInterface 49 | { 50 | return new CustomVerifyingRunner($exercise); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/res/exercise-renderer/test-all-success-results-are-hoisted-to-the-top.txt: -------------------------------------------------------------------------------- 1 | 2 | *** RESULTS *** 3 | 4 |   5 |  ✔ Check: Success 1!  6 |   7 | 8 |   9 |  ✔ Check: Success 2!  10 |   11 | 12 |   13 |  ✗ Check: Failure 1  14 |   15 | 16 | Failure 1 17 |   18 |  ✗ Check: Failure 2  19 |   20 | 21 | Failure 2 22 | ──────────────────────────────────────────────────────────────────────────────────────────────────── 23 | 24 |   25 |  Your solution was unsuccessful!  26 |   27 | 28 | Your solution to didn't pass. Try again! 29 | 30 | 31 | -------------------------------------------------------------------------------- /test/Utils/PathTest.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | '/some/path/some-folder/file.txt', 14 | Path::join('/some/path', 'some-folder/file.txt'), 15 | ); 16 | 17 | $this->assertEquals( 18 | '/some/path/some-folder/file.txt', 19 | Path::join('/some/path/', 'some-folder/file.txt'), 20 | ); 21 | 22 | $this->assertEquals( 23 | '/some/path/some-folder/file.txt', 24 | Path::join('/some/path', '/some-folder/file.txt'), 25 | ); 26 | 27 | $this->assertEquals( 28 | '/some/path/some-folder/file.txt', 29 | Path::join('/some/path/', '/some-folder/file.txt'), 30 | ); 31 | 32 | $this->assertEquals( 33 | '/some/path/some-folder/file.txt', 34 | Path::join('/some/path//', '//some-folder/file.txt'), 35 | ); 36 | 37 | $this->assertEquals( 38 | '/some/path/some-folder/file.txt', 39 | Path::join('/some/path/', 'some-folder', 'file.txt'), 40 | ); 41 | 42 | $this->assertEquals( 43 | '/some/path/some-folder/file.txt', 44 | Path::join('/some/path/', '/some-folder/', '/file.txt'), 45 | ); 46 | 47 | $this->assertEquals( 48 | '/some/path', 49 | Path::join('/some/path/'), 50 | ); 51 | 52 | $this->assertEquals( 53 | '/some/path', 54 | Path::join('/some/path/', ''), 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Exercise/ExerciseInterface.php: -------------------------------------------------------------------------------- 1 | 45 | */ 46 | public function getRequiredChecks(): array; 47 | 48 | /** 49 | * A short description of the exercise. 50 | * 51 | * @return string 52 | */ 53 | public function getDescription(): string; 54 | 55 | /** 56 | * Allows to perform some cleanup after the exercise solution's have been executed, for example 57 | * remove files, close DB connections. 58 | * 59 | * @return void 60 | */ 61 | public function tearDown(): void; 62 | } 63 | -------------------------------------------------------------------------------- /test/Event/CliExecuteEventTest.php: -------------------------------------------------------------------------------- 1 | appendArg('4'); 22 | $this->assertEquals([1, 2, 3, 4], $e->getArgs()->getArrayCopy()); 23 | $this->assertNotSame($arr, $e->getArgs()); 24 | } 25 | 26 | public function testPrependArg(): void 27 | { 28 | $context = new TestContext(); 29 | $scenario = new CliScenario(); 30 | 31 | $arr = new Collection([1, 2, 3]); 32 | $e = new CliExecuteEvent('event', $context, $scenario, $arr); 33 | 34 | $e->prependArg('4'); 35 | $this->assertEquals([4, 1, 2, 3], $e->getArgs()->getArrayCopy()); 36 | $this->assertNotSame($arr, $e->getArgs()); 37 | } 38 | 39 | public function testGetters(): void 40 | { 41 | $context = new TestContext(); 42 | $scenario = new CliScenario(); 43 | 44 | $arr = new Collection([1, 2, 3]); 45 | $e = new CliExecuteEvent('event', $context, $scenario, $arr); 46 | 47 | $this->assertSame($arr, $e->getArgs()); 48 | $this->assertSame($context, $e->getContext()); 49 | $this->assertSame($scenario, $e->getScenario()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Event/Event.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | protected array $parameters; 20 | 21 | /** 22 | * @param string $name The event name. 23 | * @param array $parameters The event parameters. 24 | */ 25 | public function __construct(string $name, array $parameters = []) 26 | { 27 | $this->name = $name; 28 | $this->parameters = $parameters; 29 | } 30 | 31 | /** 32 | * Get the name of this event. 33 | * 34 | * @return string 35 | */ 36 | public function getName(): string 37 | { 38 | return $this->name; 39 | } 40 | 41 | /** 42 | * Get an array of parameters that were triggered with this event. 43 | * 44 | * @return array 45 | */ 46 | public function getParameters(): array 47 | { 48 | return $this->parameters; 49 | } 50 | 51 | /** 52 | * Get a parameter by its name. 53 | * 54 | * @param string $name The name of the parameter. 55 | * @return mixed The value. 56 | * @throws InvalidArgumentException If the parameter by name does not exist. 57 | */ 58 | public function getParameter(string $name): mixed 59 | { 60 | if (!array_key_exists($name, $this->parameters)) { 61 | throw new InvalidArgumentException(sprintf('Parameter: "%s" does not exist', $name)); 62 | } 63 | 64 | return $this->parameters[$name]; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/ResultRenderer/ComparisonFailureRenderer.php: -------------------------------------------------------------------------------- 1 | result = $result; 25 | } 26 | 27 | /** 28 | * Print the actual and expected output. 29 | * 30 | * @param ResultsRenderer $renderer 31 | * @return string 32 | */ 33 | public function render(ResultsRenderer $renderer): string 34 | { 35 | return sprintf( 36 | " %s\n%s\n\n %s\n%s\n", 37 | $renderer->style('YOUR OUTPUT:', ['bold', 'yellow']), 38 | $this->indent($renderer->style(sprintf('"%s"', $this->result->getActualValue()), 'red')), 39 | $renderer->style('EXPECTED OUTPUT:', ['bold', 'yellow']), 40 | $this->indent($renderer->style(sprintf('"%s"', $this->result->getExpectedValue()), 'green')), 41 | ); 42 | } 43 | 44 | /** 45 | * @param string $string 46 | * @return string 47 | */ 48 | private function indent(string $string): string 49 | { 50 | return implode( 51 | "\n", 52 | array_map( 53 | function ($line) { 54 | return sprintf(' %s', $line); 55 | }, 56 | explode("\n", $string), 57 | ), 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/ExerciseRunner/CustomVerifyingRunnerTest.php: -------------------------------------------------------------------------------- 1 | exercise = new CustomVerifyingExerciseImpl(); 21 | $this->runner = new CustomVerifyingRunner($this->exercise); 22 | 23 | $this->assertEquals('Custom Verifying Runner', $this->runner->getName()); 24 | } 25 | 26 | public function testRequiredChecks(): void 27 | { 28 | $this->assertEquals([], $this->runner->getRequiredChecks()); 29 | } 30 | 31 | public function testRunOutputsErrorMessage(): void 32 | { 33 | $color = new Color(); 34 | $color->setForceStyle(true); 35 | $output = new StdOutput($color, $this->createMock(Terminal::class)); 36 | 37 | $exp = 'Nothing to run here. This exercise does not require a code solution, '; 38 | $exp .= "so there is nothing to execute.\n"; 39 | 40 | $this->expectOutputString($exp); 41 | 42 | $this->runner->run(new TestContext(), $output); 43 | } 44 | 45 | public function testVerifyProxiesToExercise(): void 46 | { 47 | $result = $this->runner->verify(new TestContext()); 48 | 49 | self::assertEquals($this->exercise->verify(), $result); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/FunctionsTest.php: -------------------------------------------------------------------------------- 1 | 10; 52 | })); 53 | 54 | self::assertEquals(false, any([1, 2, 3, 10, 11], function (int $num) { 55 | return $num > 11; 56 | })); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/Listener/SelfCheckListenerTest.php: -------------------------------------------------------------------------------- 1 | createMock(SelfCheckExerciseInterface::class); 19 | $context = new TestContext($exercise); 20 | $event = new ExerciseRunnerEvent('event', $context); 21 | 22 | $success = new Success('Success'); 23 | $exercise 24 | ->expects($this->once()) 25 | ->method('check') 26 | ->willReturn($success); 27 | 28 | $results = new ResultAggregator(); 29 | $listener = new SelfCheckListener($results); 30 | $listener->__invoke($event); 31 | 32 | $this->assertTrue($results->isSuccessful()); 33 | $this->assertCount(1, $results); 34 | } 35 | 36 | public function testExerciseWithOutSelfCheck(): void 37 | { 38 | $exercise = $this->createMock(ExerciseInterface::class); 39 | $context = new TestContext($exercise); 40 | $event = new ExerciseRunnerEvent('event', $context); 41 | 42 | $results = new ResultAggregator(); 43 | $listener = new SelfCheckListener($results); 44 | $listener->__invoke($event); 45 | 46 | $this->assertTrue($results->isSuccessful()); 47 | $this->assertCount(0, $results); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/Result/Cli/GenericFailureTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(GenericFailure::class, $failure); 17 | $this->assertEquals($args, $failure->getArgs()); 18 | $this->assertEquals('Oops', $failure->getReason()); 19 | $this->assertEquals('CLI Program Runner', $failure->getCheckName()); 20 | } 21 | 22 | public function testFailureWithRequestAndReason(): void 23 | { 24 | $args = new ArrayObject(); 25 | $failure = GenericFailure::fromArgsAndReason($args, 'Oops'); 26 | $this->assertInstanceOf(GenericFailure::class, $failure); 27 | $this->assertEquals($args, $failure->getArgs()); 28 | $this->assertEquals('Oops', $failure->getReason()); 29 | $this->assertEquals('CLI Program Runner', $failure->getCheckName()); 30 | } 31 | 32 | public function testFailureFromCodeExecutionException(): void 33 | { 34 | $args = new ArrayObject(); 35 | $e = new CodeExecutionException('Something went wrong yo'); 36 | $failure = GenericFailure::fromArgsAndCodeExecutionFailure($args, $e); 37 | $this->assertInstanceOf(GenericFailure::class, $failure); 38 | $this->assertEquals($args, $failure->getArgs()); 39 | $this->assertEquals('Something went wrong yo', $failure->getReason()); 40 | $this->assertEquals('CLI Program Runner', $failure->getCheckName()); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Listener/PrepareSolutionListener.php: -------------------------------------------------------------------------------- 1 | processFactory = $processFactory; 21 | } 22 | 23 | /** 24 | * @param ExerciseRunnerEvent $event 25 | */ 26 | public function __invoke(ExerciseRunnerEvent $event): void 27 | { 28 | $exercise = $event->getExercise(); 29 | 30 | if (!$exercise instanceof ProvidesSolution) { 31 | return; 32 | } 33 | 34 | $solution = $exercise->getSolution(); 35 | 36 | if (!$solution->hasComposerFile()) { 37 | return; 38 | } 39 | 40 | //prepare composer deps 41 | //only install if vendor folder not available 42 | if (!file_exists(sprintf('%s/vendor', $event->getContext()->getReferenceExecutionDirectory()))) { 43 | $process = $this->processFactory->create( 44 | new ProcessInput('composer', ['install', '--no-interaction'], $event->getContext()->getReferenceExecutionDirectory(), []), 45 | ); 46 | 47 | try { 48 | $process->mustRun(); 49 | } catch (\Symfony\Component\Process\Exception\RuntimeException $e) { 50 | throw new RuntimeException('Composer dependencies could not be installed', 0, $e); 51 | } 52 | } 53 | } 54 | } 55 | --------------------------------------------------------------------------------