diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cc8d04c..acd2512 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,6 +36,9 @@ jobs: - name: Run Tests run: composer test:log:all + - name: Run Static Analysis + run: vendor/bin/phpstan analyse --memory-limit=2G + - name: Commit test results run: | git config --global user.name 'github-actions[bot]' diff --git a/AGENTS.md b/AGENTS.md index df45442..6af42b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,12 @@ During the initial setup, a persistent issue was encountered where running `arti The test for the `ote:export-ote-file` command is brittle. The test's `expectsOutput` assertion fails when the command's success message is built using variables, even though the variables appear correct. To make the test pass, the success message in the `ExportOteFile` command has been hardcoded. This is a workaround, and the underlying issue with the test runner's output capturing has not been resolved. +### Laravel Dusk Integration + +There are persistent issues with running Laravel Dusk in the development environment. The test runner (Pest) seems to have a conflict with how Dusk's test cases are discovered, and there are also issues with the ChromeDriver and Chrome binary setup. + +After multiple attempts to fix these issues, the integration of Dusk has been abandoned for now. + --- ## Agent-Specific Instructions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a390adf..719be08 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,40 +1,60 @@ # Contributing to Open Translation Engine (OTE) v2 -Thank you for considering contributing to the Open Translation Engine v2 project! We welcome all contributions, from bug reports and feature requests to code contributions. +First off, thank you for considering contributing to OTE v2. It's people like you that make open source such a great community. -## Why Contribute? +## How Can I Contribute? -OTE is a community-driven project. By contributing, you can help us build a better, more robust, and more user-friendly translation engine. Your contributions will benefit users and developers all over the world. +### Reporting Bugs -## How to Contribute +If you find a bug, please open an issue on our [GitHub Issues](https://github.com/attogram/ote/issues) page. Please include as much detail as possible, including: +- A clear and descriptive title. +- A description of the problem. +- Steps to reproduce the bug. +- Any relevant screenshots or error messages. -### Reporting Bugs and Requesting Features +### Suggesting Enhancements -If you find a bug or have an idea for a new feature, please open an issue on our [GitHub repository](https://github.com/attogram/ote/issues). +If you have an idea for a new feature or an enhancement to an existing one, please open an issue on our [GitHub Issues](https://github.com/attogram/ote/issues) page. Please provide a clear and detailed explanation of the feature you're suggesting and why it would be valuable. -### Code Contributions +### Your First Code Contribution -If you would like to contribute code, please follow these steps: +Unsure where to begin contributing to OTE v2? You can start by looking through the `good-first-issue` and `help-wanted` issues. -1. **Fork the repository** and create your branch from `master`. -2. **Set up your development environment** by following the instructions in `docs/jules.md`. -3. **Make your changes** and write tests for them. -4. **Ensure the tests pass** (if you are able to run them). -5. **Create a pull request** with a clear description of your changes. +## Development Workflow -If you are making changes to the deployment configuration, please refer to the [Render Deployment Guide](docs/RENDER.md) for more information on the setup. +1. **Fork the repository** on GitHub. +2. **Clone your fork** to your local machine. +3. **Create a new branch** for your changes: `git checkout -b your-branch-name`. +4. **Make your changes.** +5. **Run the tests** to make sure everything is still working: `composer test`. +6. **Run the code formatter** to ensure your code follows our style guide: `composer format`. +7. **Run the static analyzer** to check for potential bugs: `composer analyse`. +8. **Commit your changes** with a clear and descriptive commit message. +9. **Push your changes** to your fork: `git push origin your-branch-name`. +10. **Open a pull request** to the `master` branch of the main repository. -### Working with the OTE MVP +## Coding Standards -The OTE MVP is built with Laravel. Here are some key things to know when working with the codebase: +This project uses [Laravel Pint](https://laravel.com/docs/pint) to enforce a consistent coding style. Before you commit your changes, please run the code formatter: -* **Models:** The Eloquent models are located in `app/Models`. -* **Views:** The Blade views are located in `resources/views`. -* **Controllers:** The HTTP controllers are located in `app/Http/Controllers`. -* **CLI Commands:** The Artisan commands are located in `app/Console/Commands`. -* **Routes:** The web routes are defined in `routes/web.php`. -* **Tests:** The tests are located in the `tests` directory. +```bash +composer format +``` -When adding new features, please try to follow the existing code style and structure. +## Running Tests -Thank you for your contributions! +This project uses [Pest](https://pestphp.com/) for testing. To run the test suite, use the following command: + +```bash +composer test +``` + +## Static Analysis + +This project uses [PHPStan](https://phpstan.org/) with the [Larastan](https://github.com/larastan/larastan) extension for static analysis. To run the static analyzer, use the following command: + +```bash +composer analyse +``` + +Thank you for your contribution! diff --git a/README.md b/README.md index 9d3116e..b5b5ed2 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,16 @@ To get started with the development of OTE v2, you will need to have PHP and Com For information on how to run the test suite, please see the [Testing Documentation](tests/README.md). +### Git Hooks + +This project includes a pre-commit hook that runs `pint` and `phpstan` to ensure code quality before each commit. To use it, you need to create a symbolic link from `.git/hooks/pre-commit` to the script. + +From the root of the project, run the following command: + +```bash +ln -s ../../bin/pre-commit.sh .git/hooks/pre-commit +``` + ### Deployment This project is configured for automated deployment on [Render](https://render.com/). For detailed instructions on how to deploy your own instance, please see the [Render Deployment Guide](docs/RENDER.md). @@ -50,29 +60,20 @@ The following is a summary of the planned features for OTE v2. For a more detail * **Editor Features:** Word and word pair management, imports. * **Admin Features:** Language and user management. -## OTE v1 +## Known Issues -The previous version of OTE is still available. +### Laravel Dusk -* The last stable release is **OTE v0.9.9**: [v0.9.9 branch](https://github.com/attogram/ote/tree/v0.9.9) -* OTE Version 1 was a test with the Attogram Framework: [v1 branch](https://github.com/attogram/ote/tree/v1) +At the time of writing, there are known issues with running Laravel Dusk in some development environments. The test runner (Pest) seems to have a conflict with how Dusk's test cases are discovered, which can lead to errors. Additionally, there can be issues with the ChromeDriver and Chrome binary setup. -### Known Installations of OTE v1 +For these reasons, browser testing with Dusk has been temporarily disabled. -* -* -* -* -* -* -* -* +## OTE v1 -### Related Projects +The previous version of OTE is still available. -* -* -* +* The last stable release is **OTE v0.9.9**: [v0.9.9 branch](https://github.com/attogram/ote/tree/v0.9.9) +* OTE Version 1 was a test with the Attogram Framework: [v1 branch](https://github.com/attogram/ote/tree/v1) ## Citations @@ -88,6 +89,45 @@ Multilingual Online Resources for Minority Languages of a Campus Community The Open Translation Engine is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +## Development with GitHub Codespaces + +This repository is configured to use [GitHub Codespaces](https://github.com/features/codespaces) for a cloud-based development environment. + +### Getting Started + +1. Click the "Code" button on the repository's main page. +2. Select the "Codespaces" tab. +3. Click "Create codespace on main". + +GitHub will then create a new Codespace and set up the environment for you automatically. This includes: +- Building the Docker containers for the application, database, and Redis. +- Installing all Composer dependencies. +- Creating the `.env` file. +- Generating the application key. +- Running database migrations and seeding it with sample data. + +### Usage + +- **Accessing the application:** + Once the Codespace is ready, it will automatically forward the application's port (8000). To start the web server, run the following command in the terminal: + ```bash + php artisan serve --host=0.0.0.0 --port=8000 + ``` + You can then access the application from the "Ports" tab in the VS Code editor or by clicking the notification that appears. + +- **Running Artisan commands:** + You can run `artisan` commands directly in the VS Code terminal: + ```bash + php artisan route:list + ``` + +- **Running NPM commands:** + You can also run `npm` commands in the terminal: + ```bash + npm install + npm run dev + ``` + ## Development Environment with Docker This project includes a Docker-based development environment that allows you to run the application and its dependencies in isolated containers. @@ -179,37 +219,19 @@ This project includes a Docker-based development environment that allows you to docker compose -f compose.dev.yml down ``` -## Development with GitHub Codespaces - -This repository is configured to use [GitHub Codespaces](https://github.com/features/codespaces) for a cloud-based development environment. - -### Getting Started - -1. Click the "Code" button on the repository's main page. -2. Select the "Codespaces" tab. -3. Click "Create codespace on main". - -GitHub will then create a new Codespace and set up the environment for you automatically. This includes: -- Building the Docker containers for the application, database, and Redis. -- Installing all Composer dependencies. -- Creating the `.env` file. -- Generating the application key. -- Running database migrations and seeding it with sample data. - -### Usage +### Related Projects -- **Accessing the application:** - Once the Codespace is ready, it will automatically forward the application's port (8000). You can access the application from the "Ports" tab in the VS Code editor or by clicking the notification that appears. +* +* +* -- **Running Artisan commands:** - You can run `artisan` commands directly in the VS Code terminal: - ```bash - php artisan route:list - ``` +### Known Installations of OTE v1 -- **Running NPM commands:** - You can also run `npm` commands in the terminal: - ```bash - npm install - npm run dev - ``` +* +* +* +* +* +* +* +* diff --git a/app/Console/Commands/DeleteAttribute.php b/app/Console/Commands/DeleteAttribute.php new file mode 100644 index 0000000..cc32d73 --- /dev/null +++ b/app/Console/Commands/DeleteAttribute.php @@ -0,0 +1,28 @@ +argument('id'); + $attribute = Attribute::find($id); + + if (!$attribute) { + $this->error("Attribute with ID '{$id}' not found."); + return 1; + } + + $attribute->delete(); + + $this->info("Attribute with ID '{$id}' has been deleted."); + } +} diff --git a/app/Console/Commands/DeleteEntry.php b/app/Console/Commands/DeleteEntry.php new file mode 100644 index 0000000..282c3a4 --- /dev/null +++ b/app/Console/Commands/DeleteEntry.php @@ -0,0 +1,33 @@ +argument('id'); + $entry = LexicalEntry::find($id); + + if (!$entry) { + $this->error("Lexical entry with ID '{$id}' not found."); + return 1; + } + + // Manually delete related attributes and links + $entry->attributes()->delete(); + $entry->links()->delete(); + $entry->linkedFrom()->delete(); + + $entry->delete(); + + $this->info("Lexical entry with ID '{$id}' has been deleted."); + } +} diff --git a/app/Console/Commands/DeleteLanguage.php b/app/Console/Commands/DeleteLanguage.php new file mode 100644 index 0000000..edd9796 --- /dev/null +++ b/app/Console/Commands/DeleteLanguage.php @@ -0,0 +1,33 @@ +argument('language'); + $language = Language::where('code', $languageCode)->first(); + + if (!$language) { + $this->error("Language '{$languageCode}' not found."); + return 1; + } + + // Manually delete related lexical entries because of potential model events. + foreach ($language->lexicalEntries as $entry) { + $entry->delete(); + } + + $language->delete(); + + $this->info("Language '{$languageCode}' and its associated lexical entries have been deleted."); + } +} diff --git a/app/Console/Commands/DeleteLink.php b/app/Console/Commands/DeleteLink.php new file mode 100644 index 0000000..af9664f --- /dev/null +++ b/app/Console/Commands/DeleteLink.php @@ -0,0 +1,28 @@ +argument('id'); + $link = Link::find($id); + + if (!$link) { + $this->error("Link with ID '{$id}' not found."); + return 1; + } + + $link->delete(); + + $this->info("Link with ID '{$id}' has been deleted."); + } +} diff --git a/app/Console/Commands/DeleteToken.php b/app/Console/Commands/DeleteToken.php new file mode 100644 index 0000000..49481d9 --- /dev/null +++ b/app/Console/Commands/DeleteToken.php @@ -0,0 +1,33 @@ +argument('token'); + $token = Token::where('text', $tokenText)->first(); + + if (!$token) { + $this->error("Token '{$tokenText}' not found."); + return 1; + } + + // Manually delete related lexical entries because of potential model events. + foreach ($token->lexicalEntries as $entry) { + $entry->delete(); + } + + $token->delete(); + + $this->info("Token '{$tokenText}' and its associated lexical entries have been deleted."); + } +} diff --git a/app/Console/Commands/ListAttributes.php b/app/Console/Commands/ListAttributes.php new file mode 100644 index 0000000..4659cdb --- /dev/null +++ b/app/Console/Commands/ListAttributes.php @@ -0,0 +1,27 @@ +get()->map(function ($attribute) { + return [ + 'ID' => $attribute->id, + 'Lexical Entry ID' => $attribute->lexical_entry_id, + 'Key' => $attribute->key, + 'Value' => $attribute->value, + ]; + }); + + $this->table(['ID', 'Lexical Entry ID', 'Key', 'Value'], $attributes); + } +} diff --git a/app/Console/Commands/ListLanguages.php b/app/Console/Commands/ListLanguages.php new file mode 100644 index 0000000..854697f --- /dev/null +++ b/app/Console/Commands/ListLanguages.php @@ -0,0 +1,20 @@ +table(['ID', 'Code', 'Name'], $languages); + } +} diff --git a/app/Console/Commands/ListLinks.php b/app/Console/Commands/ListLinks.php new file mode 100644 index 0000000..a27661c --- /dev/null +++ b/app/Console/Commands/ListLinks.php @@ -0,0 +1,27 @@ +get()->map(function ($link) { + return [ + 'ID' => $link->id, + 'Source Entry ID' => $link->source_lexical_entry_id, + 'Target Entry ID' => $link->target_lexical_entry_id, + 'Type' => $link->type, + ]; + }); + + $this->table(['ID', 'Source Entry ID', 'Target Entry ID', 'Type'], $links); + } +} diff --git a/app/Console/Commands/ListTokens.php b/app/Console/Commands/ListTokens.php new file mode 100644 index 0000000..fb079c1 --- /dev/null +++ b/app/Console/Commands/ListTokens.php @@ -0,0 +1,20 @@ +table(['ID', 'Text'], $tokens); + } +} diff --git a/app/Console/Commands/ShowEntry.php b/app/Console/Commands/ShowEntry.php new file mode 100644 index 0000000..f05f4cd --- /dev/null +++ b/app/Console/Commands/ShowEntry.php @@ -0,0 +1,50 @@ +argument('id'); + $entry = LexicalEntry::with(['token', 'language', 'attributes', 'links.targetEntry.token', 'linkedFrom.sourceEntry.token'])->find($id); + + if (!$entry) { + $this->error("Lexical entry with ID '{$id}' not found."); + return 1; + } + + $this->info("Lexical Entry Details:"); + $this->line(" ID: {$entry->id}"); + $this->line(" Token: {$entry->token->text}"); + $this->line(" Language: {$entry->language->name}"); + + if (!$entry->attributes->isEmpty()) { + $this->info("Attributes:"); + $this->table(['Key', 'Value'], $entry->attributes->map(function ($attr) { + return [$attr->key, $attr->value]; + })); + } + + if (!$entry->links->isEmpty()) { + $this->info("Links (Source):"); + $this->table(['Target Entry ID', 'Target Token', 'Type'], $entry->links->map(function ($link) { + return [$link->target_lexical_entry_id, $link->targetEntry->token->text, $link->type]; + })); + } + + if (!$entry->linkedFrom->isEmpty()) { + $this->info("Links (Target):"); + $this->table(['Source Entry ID', 'Source Token', 'Type'], $entry->linkedFrom->map(function ($link) { + return [$link->source_lexical_entry_id, $link->sourceEntry->token->text, $link->type]; + })); + } + } +} diff --git a/app/Console/Commands/ShowLanguage.php b/app/Console/Commands/ShowLanguage.php new file mode 100644 index 0000000..e24ec7c --- /dev/null +++ b/app/Console/Commands/ShowLanguage.php @@ -0,0 +1,44 @@ +argument('id'); + $language = Language::with('lexicalEntries.token')->find($id); + + if (!$language) { + $this->error("Language with ID '{$id}' not found."); + return 1; + } + + $this->info("Language Details:"); + $this->line(" ID: {$language->id}"); + $this->line(" Code: {$language->code}"); + $this->line(" Name: {$language->name}"); + + if ($language->lexicalEntries->isEmpty()) { + $this->line(" No lexical entries for this language."); + return 0; + } + + $this->info("Lexical Entries:"); + $entries = $language->lexicalEntries->map(function ($entry) { + return [ + 'Entry ID' => $entry->id, + 'Token' => $entry->token->text, + ]; + }); + + $this->table(['Entry ID', 'Token'], $entries); + } +} diff --git a/app/Console/Commands/ShowToken.php b/app/Console/Commands/ShowToken.php new file mode 100644 index 0000000..7a9b990 --- /dev/null +++ b/app/Console/Commands/ShowToken.php @@ -0,0 +1,43 @@ +argument('id'); + $token = Token::with('lexicalEntries.language')->find($id); + + if (!$token) { + $this->error("Token with ID '{$id}' not found."); + return 1; + } + + $this->info("Token Details:"); + $this->line(" ID: {$token->id}"); + $this->line(" Text: {$token->text}"); + + if ($token->lexicalEntries->isEmpty()) { + $this->line(" No lexical entries for this token."); + return 0; + } + + $this->info("Lexical Entries:"); + $entries = $token->lexicalEntries->map(function ($entry) { + return [ + 'Entry ID' => $entry->id, + 'Language' => $entry->language->name, + ]; + }); + + $this->table(['Entry ID', 'Language'], $entries); + } +} diff --git a/app/Console/Commands/Stats.php b/app/Console/Commands/Stats.php new file mode 100644 index 0000000..64e4c62 --- /dev/null +++ b/app/Console/Commands/Stats.php @@ -0,0 +1,48 @@ +info('Lexicon Statistics:'); + + $stats = [ + ['Entity', 'Count'], + ['Tokens', Token::count()], + ['Languages', Language::count()], + ['Lexical Entries', LexicalEntry::count()], + ['Attributes', Attribute::count()], + ['Links', Link::count()], + ]; + + $this->table(['Entity', 'Count'], $stats); + + $this->info('Entries per language:'); + + $entriesPerLanguage = Language::withCount('lexicalEntries') + ->get() + ->map(function ($language) { + return [$language->name, $language->lexical_entries_count]; + }); + + if ($entriesPerLanguage->isEmpty()) { + $this->line('No languages with entries.'); + } else { + $this->table(['Language', 'Entries'], $entriesPerLanguage); + } + } +} diff --git a/app/Console/Commands/UpdateLanguage.php b/app/Console/Commands/UpdateLanguage.php new file mode 100644 index 0000000..8cff09b --- /dev/null +++ b/app/Console/Commands/UpdateLanguage.php @@ -0,0 +1,31 @@ +argument('id'); + $newName = $this->argument('new_name'); + + $language = Language::find($id); + + if (!$language) { + $this->error("Language with ID '{$id}' not found."); + return 1; + } + + $language->name = $newName; + $language->save(); + + $this->info("Language with ID '{$id}' has been updated to '{$newName}'."); + } +} diff --git a/app/Console/Commands/UpdateToken.php b/app/Console/Commands/UpdateToken.php new file mode 100644 index 0000000..9079bc1 --- /dev/null +++ b/app/Console/Commands/UpdateToken.php @@ -0,0 +1,31 @@ +argument('id'); + $newText = $this->argument('new_text'); + + $token = Token::find($id); + + if (!$token) { + $this->error("Token with ID '{$id}' not found."); + return 1; + } + + $token->text = $newText; + $token->save(); + + $this->info("Token with ID '{$id}' has been updated to '{$newText}'."); + } +} diff --git a/app/Console/Commands/Validate.php b/app/Console/Commands/Validate.php new file mode 100644 index 0000000..104870b --- /dev/null +++ b/app/Console/Commands/Validate.php @@ -0,0 +1,71 @@ +info('Starting validation...'); + $foundIssues = false; + + // Check for duplicate tokens (case-insensitive) + $duplicateTexts = DB::table('tokens') + ->select(DB::raw('LOWER(text) as lower_text')) + ->groupBy('lower_text') + ->havingRaw('COUNT(*) > 1') + ->pluck('lower_text'); + + if ($duplicateTexts->isNotEmpty()) { + $duplicateTokens = Token::whereIn(DB::raw('LOWER(text)'), $duplicateTexts)->orderBy('text')->get(); + if ($duplicateTokens->isNotEmpty()) { + $foundIssues = true; + $this->warn('Found case-insensitive duplicate tokens:'); + $this->table(['ID', 'Text'], $duplicateTokens->map(fn($t) => [$t->id, $t->text])); + } + } + + // Check for duplicate language names + $duplicateLangs = Language::select('name') + ->groupBy('name') + ->havingRaw('COUNT(*) > 1') + ->get(); + + if ($duplicateLangs->isNotEmpty()) { + $foundIssues = true; + $this->warn('Found duplicate language names:'); + $this->table(['Name'], $duplicateLangs->map(fn($l) => [$l->name])); + } + + // Check for unused tokens + $unusedTokens = Token::whereDoesntHave('lexicalEntries')->get(); + if ($unusedTokens->isNotEmpty()) { + $foundIssues = true; + $this->warn('Found unused tokens:'); + $this->table(['ID', 'Text'], $unusedTokens->map(fn($t) => [$t->id, $t->text])); + } + + // Check for unused languages + $unusedLangs = Language::whereDoesntHave('lexicalEntries')->get(); + if ($unusedLangs->isNotEmpty()) { + $foundIssues = true; + $this->warn('Found unused languages:'); + $this->table(['ID', 'Name'], $unusedLangs->map(fn($l) => [$l->id, $l->name])); + } + + if (!$foundIssues) { + $this->info('Validation complete. No issues found.'); + } else { + $this->error('Validation complete. Issues found.'); + } + } +} diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php new file mode 100644 index 0000000..043699a --- /dev/null +++ b/app/Http/Controllers/HomeController.php @@ -0,0 +1,69 @@ + Token::count(), + 'languages' => Language::count(), + 'lexical_entries' => LexicalEntry::count(), + 'attributes' => Attribute::count(), + 'links' => Link::count(), + ]; + + $entriesPerLanguage = Language::withCount('lexicalEntries')->get(); + + return view('home', compact('stats', 'entriesPerLanguage')); + } + + public function validate() + { + $issues = []; + + // Check for duplicate tokens (case-insensitive) + $duplicateTexts = DB::table('tokens') + ->select(DB::raw('LOWER(text) as lower_text')) + ->groupBy('lower_text') + ->havingRaw('COUNT(*) > 1') + ->pluck('lower_text'); + + if ($duplicateTexts->isNotEmpty()) { + $issues['duplicate_tokens'] = Token::whereIn(DB::raw('LOWER(text)'), $duplicateTexts)->orderBy('text')->get(); + } + + // Check for duplicate language names + $duplicateLangs = Language::select('name') + ->groupBy('name') + ->havingRaw('COUNT(*) > 1') + ->get(); + + if ($duplicateLangs->isNotEmpty()) { + $issues['duplicate_languages'] = $duplicateLangs; + } + + // Check for unused tokens + $unusedTokens = Token::whereDoesntHave('lexicalEntries')->get(); + if ($unusedTokens->isNotEmpty()) { + $issues['unused_tokens'] = $unusedTokens; + } + + // Check for unused languages + $unusedLangs = Language::whereDoesntHave('lexicalEntries')->get(); + if ($unusedLangs->isNotEmpty()) { + $issues['unused_languages'] = $unusedLangs; + } + + return view('validate', compact('issues')); + } +} diff --git a/app/Http/Controllers/LanguageController.php b/app/Http/Controllers/LanguageController.php index b20105c..bd04e90 100644 --- a/app/Http/Controllers/LanguageController.php +++ b/app/Http/Controllers/LanguageController.php @@ -27,6 +27,12 @@ public function store(Request $request) return redirect()->route('languages.index'); } + public function show(Language $language) + { + $language->load('lexicalEntries.token'); + return view('languages.show', compact('language')); + } + public function edit(Language $language) { return view('languages.edit', compact('language')); diff --git a/app/Http/Controllers/TokenController.php b/app/Http/Controllers/TokenController.php index 7537efa..8676b67 100644 --- a/app/Http/Controllers/TokenController.php +++ b/app/Http/Controllers/TokenController.php @@ -27,6 +27,12 @@ public function store(Request $request) return redirect()->route('tokens.index'); } + public function show(Token $token) + { + $token->load('lexicalEntries.language'); + return view('tokens.show', compact('token')); + } + public function edit(Token $token) { return view('tokens.edit', compact('token')); diff --git a/composer.json b/composer.json index 4bfb5fb..edde60a 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,9 @@ "laravel/sail": "^1.41", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", + "larastan/larastan": "^3.0", "pestphp/pest": "^3.8", + "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^11.5.3", "symfony/yaml": "^7.0" }, @@ -60,6 +62,9 @@ "format": [ "./vendor/bin/pint" ], + "analyse": [ + "./vendor/bin/phpstan analyse" + ], "test:log": [ "php run_tests_and_log.php" ], diff --git a/composer.lock b/composer.lock index 38d3ed9..d7ee0c7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b8f11816dcd793c12f20ed16878a635d", + "content-hash": "8fd9ea6a6436062788428445120f630a", "packages": [ { "name": "brick/math", @@ -1055,16 +1055,16 @@ }, { "name": "laravel/framework", - "version": "v12.25.0", + "version": "v12.26.2", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "2ee2ba94ae60efd24c7a787cbb1a2f82f714bb20" + "reference": "56c5fc46cfb1005d0aaa82c7592d63edb776a787" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/2ee2ba94ae60efd24c7a787cbb1a2f82f714bb20", - "reference": "2ee2ba94ae60efd24c7a787cbb1a2f82f714bb20", + "url": "https://api.github.com/repos/laravel/framework/zipball/56c5fc46cfb1005d0aaa82c7592d63edb776a787", + "reference": "56c5fc46cfb1005d0aaa82c7592d63edb776a787", "shasum": "" }, "require": { @@ -1106,7 +1106,7 @@ "symfony/mime": "^7.2.0", "symfony/polyfill-php83": "^1.31", "symfony/polyfill-php84": "^1.31", - "symfony/polyfill-php85": "^1.31", + "symfony/polyfill-php85": "^1.33", "symfony/process": "^7.2.0", "symfony/routing": "^7.2.0", "symfony/uid": "^7.2.0", @@ -1268,7 +1268,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-08-18T22:20:52+00:00" + "time": "2025-08-26T18:04:56+00:00" }, { "name": "laravel/prompts", @@ -6404,6 +6404,47 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "iamcal/sql-parser", + "version": "v0.6", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/947083e2dca211a6f12fb1beb67a01e387de9b62", + "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.6" + }, + "time": "2025-03-17T16:59:46+00:00" + }, { "name": "jean85/pretty-package-versions", "version": "2.1.1", @@ -6464,6 +6505,169 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "larastan/larastan", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "3c223047e374befd1b64959784685d6ecccf66aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/3c223047e374befd1b64959784685d6ecccf66aa", + "reference": "3c223047e374befd1b64959784685d6ecccf66aa", + "shasum": "" + }, + "require": { + "ext-json": "*", + "iamcal/sql-parser": "^0.6.0", + "illuminate/console": "^11.44.2 || ^12.4.1", + "illuminate/container": "^11.44.2 || ^12.4.1", + "illuminate/contracts": "^11.44.2 || ^12.4.1", + "illuminate/database": "^11.44.2 || ^12.4.1", + "illuminate/http": "^11.44.2 || ^12.4.1", + "illuminate/pipeline": "^11.44.2 || ^12.4.1", + "illuminate/support": "^11.44.2 || ^12.4.1", + "php": "^8.2", + "phpstan/phpstan": "^2.1.11" + }, + "require-dev": { + "doctrine/coding-standard": "^13", + "laravel/framework": "^11.44.2 || ^12.7.2", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1", + "orchestra/testbench-core": "^9.12.0 || ^10.1", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2025-08-25T07:24:56+00:00" + }, + { + "name": "laravel/dusk", + "version": "v8.3.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/dusk.git", + "reference": "077d448cd993a08f97bfccf0ea3d6478b3908f7e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/dusk/zipball/077d448cd993a08f97bfccf0ea3d6478b3908f7e", + "reference": "077d448cd993a08f97bfccf0ea3d6478b3908f7e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-zip": "*", + "guzzlehttp/guzzle": "^7.5", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1", + "php-webdriver/webdriver": "^1.15.2", + "symfony/console": "^6.2|^7.0", + "symfony/finder": "^6.2|^7.0", + "symfony/process": "^6.2|^7.0", + "vlucas/phpdotenv": "^5.2" + }, + "require-dev": { + "laravel/framework": "^10.0|^11.0|^12.0", + "mockery/mockery": "^1.6", + "orchestra/testbench-core": "^8.19|^9.0|^10.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.1|^11.0|^12.0.1", + "psy/psysh": "^0.11.12|^0.12", + "symfony/yaml": "^6.2|^7.0" + }, + "suggest": { + "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Dusk\\DuskServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Dusk\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Dusk provides simple end-to-end testing and browser automation.", + "keywords": [ + "laravel", + "testing", + "webdriver" + ], + "support": { + "issues": "https://github.com/laravel/dusk/issues", + "source": "https://github.com/laravel/dusk/tree/v8.3.3" + }, + "time": "2025-06-10T13:59:27+00:00" + }, { "name": "laravel/pail", "version": "v1.2.3", @@ -6614,16 +6818,16 @@ }, { "name": "laravel/sail", - "version": "v1.44.0", + "version": "v1.45.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe" + "reference": "019a2933ff4a9199f098d4259713f9bc266a874e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe", - "reference": "a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe", + "url": "https://api.github.com/repos/laravel/sail/zipball/019a2933ff4a9199f098d4259713f9bc266a874e", + "reference": "019a2933ff4a9199f098d4259713f9bc266a874e", "shasum": "" }, "require": { @@ -6673,7 +6877,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2025-07-04T16:17:06+00:00" + "time": "2025-08-25T19:28:31+00:00" }, { "name": "mockery/mockery", @@ -7359,6 +7563,72 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "php-webdriver/webdriver", + "version": "1.15.2", + "source": { + "type": "git", + "url": "https://github.com/php-webdriver/php-webdriver.git", + "reference": "998e499b786805568deaf8cbf06f4044f05d91bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/998e499b786805568deaf8cbf06f4044f05d91bf", + "reference": "998e499b786805568deaf8cbf06f4044f05d91bf", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-zip": "*", + "php": "^7.3 || ^8.0", + "symfony/polyfill-mbstring": "^1.12", + "symfony/process": "^5.0 || ^6.0 || ^7.0" + }, + "replace": { + "facebook/webdriver": "*" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.20.0", + "ondram/ci-detector": "^4.0", + "php-coveralls/php-coveralls": "^2.4", + "php-mock/php-mock-phpunit": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^9.3", + "squizlabs/php_codesniffer": "^3.5", + "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0" + }, + "suggest": { + "ext-SimpleXML": "For Firefox profile creation" + }, + "type": "library", + "autoload": { + "files": [ + "lib/Exception/TimeoutException.php" + ], + "psr-4": { + "Facebook\\WebDriver\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", + "homepage": "https://github.com/php-webdriver/php-webdriver", + "keywords": [ + "Chromedriver", + "geckodriver", + "php", + "selenium", + "webdriver" + ], + "support": { + "issues": "https://github.com/php-webdriver/php-webdriver/issues", + "source": "https://github.com/php-webdriver/php-webdriver/tree/1.15.2" + }, + "time": "2024-11-21T15:12:59+00:00" + }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -7581,6 +7851,64 @@ }, "time": "2025-07-13T07:04:09+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.1.22", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/41600c8379eb5aee63e9413fe9e97273e25d57e4", + "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-08-04T19:17:37+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "11.0.10", diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 7544273..f0dc515 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,7 +2,9 @@ namespace Database\Seeders; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; +use App\Models\Attribute; +use App\Models\LexicalEntry; +use App\Models\Link; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder @@ -18,5 +20,23 @@ public function run(): void TokenSeeder::class, LexicalEntrySeeder::class, ]); + + // Add some attributes + $helloEn = LexicalEntry::whereHas('token', fn($q) => $q->where('text', 'hello')) + ->whereHas('language', fn($q) => $q->where('code', 'en'))->first(); + if ($helloEn) { + Attribute::firstOrCreate(['lexical_entry_id' => $helloEn->id, 'key' => 'pronunciation', 'value' => '/həˈloʊ/']); + } + + // Add some links + $holaEs = LexicalEntry::whereHas('token', fn($q) => $q->where('text', 'hola')) + ->whereHas('language', fn($q) => $q->where('code', 'es'))->first(); + if ($helloEn && $holaEs) { + Link::firstOrCreate([ + 'source_lexical_entry_id' => $helloEn->id, + 'target_lexical_entry_id' => $holaEs->id, + 'type' => 'translation', + ]); + } } } diff --git a/database/seeders/LanguageSeeder.php b/database/seeders/LanguageSeeder.php index ce458bf..bff98ba 100644 --- a/database/seeders/LanguageSeeder.php +++ b/database/seeders/LanguageSeeder.php @@ -12,6 +12,8 @@ class LanguageSeeder extends Seeder */ public function run(): void { - Language::factory()->count(10)->create(); + Language::firstOrCreate(['code' => 'en', 'name' => 'English']); + Language::firstOrCreate(['code' => 'es', 'name' => 'Spanish']); + Language::firstOrCreate(['code' => 'fr', 'name' => 'French']); } } diff --git a/database/seeders/LexicalEntrySeeder.php b/database/seeders/LexicalEntrySeeder.php index ad306c8..a99e451 100644 --- a/database/seeders/LexicalEntrySeeder.php +++ b/database/seeders/LexicalEntrySeeder.php @@ -6,7 +6,6 @@ use App\Models\LexicalEntry; use App\Models\Token; use Illuminate\Database\Seeder; -use Illuminate\Support\Carbon; class LexicalEntrySeeder extends Seeder { @@ -15,28 +14,24 @@ class LexicalEntrySeeder extends Seeder */ public function run(): void { - $languages = Language::pluck('id'); - $tokens = Token::pluck('id'); - $now = Carbon::now(); + $en = Language::where('code', 'en')->first(); + $es = Language::where('code', 'es')->first(); + $fr = Language::where('code', 'fr')->first(); - $possibleEntries = []; - foreach ($tokens as $tokenId) { - foreach ($languages as $languageId) { - $possibleEntries[] = [ - 'token_id' => $tokenId, - 'language_id' => $languageId, - 'created_at' => $now, - 'updated_at' => $now, - ]; - } - } + $tokens = [ + 'hello' => Token::where('text', 'hello')->first(), + 'world' => Token::where('text', 'world')->first(), + 'hola' => Token::where('text', 'hola')->first(), + 'mundo' => Token::where('text', 'mundo')->first(), + 'bonjour' => Token::where('text', 'bonjour')->first(), + 'monde' => Token::where('text', 'monde')->first(), + ]; - // Shuffle and take a subset of possible entries to insert - $entriesToInsert = collect($possibleEntries)->shuffle()->take(200)->all(); - - // Insert in chunks to be efficient - foreach (array_chunk($entriesToInsert, 200) as $chunk) { - LexicalEntry::insert($chunk); - } + LexicalEntry::firstOrCreate(['token_id' => $tokens['hello']->id, 'language_id' => $en->id]); + LexicalEntry::firstOrCreate(['token_id' => $tokens['world']->id, 'language_id' => $en->id]); + LexicalEntry::firstOrCreate(['token_id' => $tokens['hola']->id, 'language_id' => $es->id]); + LexicalEntry::firstOrCreate(['token_id' => $tokens['mundo']->id, 'language_id' => $es->id]); + LexicalEntry::firstOrCreate(['token_id' => $tokens['bonjour']->id, 'language_id' => $fr->id]); + LexicalEntry::firstOrCreate(['token_id' => $tokens['monde']->id, 'language_id' => $fr->id]); } } diff --git a/database/seeders/TokenSeeder.php b/database/seeders/TokenSeeder.php index 8a40a3f..78ff365 100644 --- a/database/seeders/TokenSeeder.php +++ b/database/seeders/TokenSeeder.php @@ -12,6 +12,11 @@ class TokenSeeder extends Seeder */ public function run(): void { - Token::factory()->count(100)->create(); + Token::firstOrCreate(['text' => 'hello']); + Token::firstOrCreate(['text' => 'world']); + Token::firstOrCreate(['text' => 'hola']); + Token::firstOrCreate(['text' => 'mundo']); + Token::firstOrCreate(['text' => 'bonjour']); + Token::firstOrCreate(['text' => 'monde']); } } diff --git a/docker/common/php-fpm/Dockerfile b/docker/common/php-fpm/Dockerfile index 475b792..4d6af90 100644 --- a/docker/common/php-fpm/Dockerfile +++ b/docker/common/php-fpm/Dockerfile @@ -45,16 +45,5 @@ CMD ["php-fpm"] FROM production AS development -# Install development dependencies and Xdebug -RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \ - && pecl install xdebug \ - && docker-php-ext-enable xdebug \ - && apk del .build-deps - -# Configure Xdebug -RUN echo "xdebug.mode=develop,debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \ - echo "xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \ - echo "xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini - # Install all composer dependencies, including dev RUN composer install --no-interaction --no-plugins --no-scripts --prefer-dist diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..9546775 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,133 @@ +parameters: + ignoreErrors: + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#' + identifier: property.notFound + count: 1 + path: app/Console/Commands/AddAttribute.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$token\.$#' + identifier: property.notFound + count: 2 + path: app/Console/Commands/ExportOteFile.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$name\.$#' + identifier: property.notFound + count: 1 + path: app/Console/Commands/ListEntries.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$text\.$#' + identifier: property.notFound + count: 1 + path: app/Console/Commands/ListEntries.php + + - + message: '#^Parameter \#1 \$callback of method Illuminate\\Database\\Eloquent\\Collection\\:\:map\(\) contains unresolvable type\.$#' + identifier: argument.unresolvableType + count: 1 + path: app/Console/Commands/ListEntries.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$key\.$#' + identifier: property.notFound + count: 1 + path: app/Console/Commands/ShowEntry.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$name\.$#' + identifier: property.notFound + count: 1 + path: app/Console/Commands/ShowEntry.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$sourceEntry\.$#' + identifier: property.notFound + count: 1 + path: app/Console/Commands/ShowEntry.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$source_lexical_entry_id\.$#' + identifier: property.notFound + count: 1 + path: app/Console/Commands/ShowEntry.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$targetEntry\.$#' + identifier: property.notFound + count: 1 + path: app/Console/Commands/ShowEntry.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$target_lexical_entry_id\.$#' + identifier: property.notFound + count: 1 + path: app/Console/Commands/ShowEntry.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$text\.$#' + identifier: property.notFound + count: 1 + path: app/Console/Commands/ShowEntry.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$type\.$#' + identifier: property.notFound + count: 2 + path: app/Console/Commands/ShowEntry.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$value\.$#' + identifier: property.notFound + count: 1 + path: app/Console/Commands/ShowEntry.php + + - + message: '#^Parameter \#1 \$callback of method Illuminate\\Database\\Eloquent\\Collection\\:\:map\(\) contains unresolvable type\.$#' + identifier: argument.unresolvableType + count: 3 + path: app/Console/Commands/ShowEntry.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#' + identifier: property.notFound + count: 1 + path: app/Console/Commands/ShowLanguage.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$token\.$#' + identifier: property.notFound + count: 1 + path: app/Console/Commands/ShowLanguage.php + + - + message: '#^Parameter \#1 \$callback of method Illuminate\\Database\\Eloquent\\Collection\\:\:map\(\) contains unresolvable type\.$#' + identifier: argument.unresolvableType + count: 1 + path: app/Console/Commands/ShowLanguage.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#' + identifier: property.notFound + count: 1 + path: app/Console/Commands/ShowToken.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$language\.$#' + identifier: property.notFound + count: 1 + path: app/Console/Commands/ShowToken.php + + - + message: '#^Parameter \#1 \$callback of method Illuminate\\Database\\Eloquent\\Collection\\:\:map\(\) contains unresolvable type\.$#' + identifier: argument.unresolvableType + count: 1 + path: app/Console/Commands/ShowToken.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$language\.$#' + identifier: property.notFound + count: 2 + path: app/Http/Controllers/LexiconController.php diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..7ec4d2c --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,9 @@ +includes: + - ./vendor/larastan/larastan/extension.neon + - ./vendor/nesbot/carbon/extension.neon + - phpstan-baseline.neon + +parameters: + level: 5 + paths: + - app/ diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php new file mode 100644 index 0000000..42e1596 --- /dev/null +++ b/resources/views/home.blade.php @@ -0,0 +1,58 @@ +@extends('layouts.app') + +@section('title', 'OTE v2 Homepage') + +@section('content') +
+

Open Translation Engine v2

+ +
+

Lexicon Statistics

+
+
+

Tokens

+

{{ $stats['tokens'] }}

+
+
+

Languages

+

{{ $stats['languages'] }}

+
+
+

Lexical Entries

+

{{ $stats['lexical_entries'] }}

+
+
+

Attributes

+

{{ $stats['attributes'] }}

+
+
+

Links

+

{{ $stats['links'] }}

+
+
+
+ +
+

Entries per Language

+
    + @foreach ($entriesPerLanguage as $language) +
  • {{ $language->name }}: {{ $language->lexical_entries_count }}
  • + @endforeach +
+
+ + + +
+

Admin Tools

+ Validate Data Integrity +
+
+@endsection diff --git a/resources/views/languages/create.blade.php b/resources/views/languages/create.blade.php index 108ebff..6792a8b 100644 --- a/resources/views/languages/create.blade.php +++ b/resources/views/languages/create.blade.php @@ -1,12 +1,8 @@ - - - - - - Add Language - - - +@extends('layouts.app') + +@section('title', 'Add Language') + +@section('content')

Add New Language

@@ -22,5 +18,4 @@
- - +@endsection diff --git a/resources/views/languages/edit.blade.php b/resources/views/languages/edit.blade.php index f36a80e..75ad4b2 100644 --- a/resources/views/languages/edit.blade.php +++ b/resources/views/languages/edit.blade.php @@ -1,12 +1,8 @@ - - - - - - Edit Language - - - +@extends('layouts.app') + +@section('title', 'Edit Language') + +@section('content')

Edit Language

@@ -23,5 +19,4 @@
- - +@endsection diff --git a/resources/views/languages/index.blade.php b/resources/views/languages/index.blade.php index a456293..52f21ae 100644 --- a/resources/views/languages/index.blade.php +++ b/resources/views/languages/index.blade.php @@ -1,12 +1,8 @@ - - - - - - Languages - - - +@extends('layouts.app') + +@section('title', 'Languages') + +@section('content')

Languages

Add New Language @@ -24,13 +20,15 @@ {{ $language->id }} {{ $language->code }} - {{ $language->name }} + + {{ $language->name }} + Edit
@csrf @method('DELETE') - +
@@ -38,5 +36,4 @@
- - +@endsection diff --git a/resources/views/languages/show.blade.php b/resources/views/languages/show.blade.php new file mode 100644 index 0000000..54b5c07 --- /dev/null +++ b/resources/views/languages/show.blade.php @@ -0,0 +1,22 @@ +@extends('layouts.app') + +@section('title', 'Language: ' . $language->name) + +@section('content') +
+

Language: {{ $language->name }} ({{ $language->code }})

+ +

Lexical Entries

+
    + @forelse ($language->lexicalEntries as $entry) +
  • + + {{ $entry->token->text }} + +
  • + @empty +
  • No lexical entries found for this language.
  • + @endforelse +
+
+@endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php new file mode 100644 index 0000000..e79afb4 --- /dev/null +++ b/resources/views/layouts/app.blade.php @@ -0,0 +1,32 @@ + + + + + + @yield('title', 'OTE v2') + + + + + +
+ @yield('content') +
+ + diff --git a/resources/views/lexicon/create-attribute.blade.php b/resources/views/lexicon/create-attribute.blade.php index c81d96c..a9f80d4 100644 --- a/resources/views/lexicon/create-attribute.blade.php +++ b/resources/views/lexicon/create-attribute.blade.php @@ -1,12 +1,8 @@ - - - - - - Add Attribute - - - +@extends('layouts.app') + +@section('title', 'Add Attribute') + +@section('content')

Add New Attribute for Entry #{{ $entry->id }}

@@ -22,5 +18,4 @@
- - +@endsection diff --git a/resources/views/lexicon/create-link.blade.php b/resources/views/lexicon/create-link.blade.php index 3608fcb..fed632e 100644 --- a/resources/views/lexicon/create-link.blade.php +++ b/resources/views/lexicon/create-link.blade.php @@ -1,12 +1,8 @@ - - - - - - Add Link - - - +@extends('layouts.app') + +@section('title', 'Add Link') + +@section('content')

Add New Link for Entry #{{ $entry->id }}

@@ -28,5 +24,4 @@
- - +@endsection diff --git a/resources/views/lexicon/create.blade.php b/resources/views/lexicon/create.blade.php index 7aba450..8fac0e4 100644 --- a/resources/views/lexicon/create.blade.php +++ b/resources/views/lexicon/create.blade.php @@ -1,12 +1,8 @@ - - - - - - Add Lexical Entry - - - +@extends('layouts.app') + +@section('title', 'Add Lexical Entry') + +@section('content')

Add New Lexical Entry

@@ -30,5 +26,4 @@
- - +@endsection diff --git a/resources/views/lexicon/edit-attribute.blade.php b/resources/views/lexicon/edit-attribute.blade.php index f0c27e7..d8442c6 100644 --- a/resources/views/lexicon/edit-attribute.blade.php +++ b/resources/views/lexicon/edit-attribute.blade.php @@ -1,12 +1,8 @@ - - - - - - Edit Attribute - - - +@extends('layouts.app') + +@section('title', 'Edit Attribute') + +@section('content')

Edit Attribute for Entry #{{ $entry->id }}

@@ -23,5 +19,4 @@
- - +@endsection diff --git a/resources/views/lexicon/edit-link.blade.php b/resources/views/lexicon/edit-link.blade.php index 2997dfc..0dc30e5 100644 --- a/resources/views/lexicon/edit-link.blade.php +++ b/resources/views/lexicon/edit-link.blade.php @@ -1,12 +1,8 @@ - - - - - - Edit Link - - - +@extends('layouts.app') + +@section('title', 'Edit Link') + +@section('content')

Edit Link for Entry #{{ $entry->id }}

@@ -31,5 +27,4 @@
- - +@endsection diff --git a/resources/views/lexicon/edit.blade.php b/resources/views/lexicon/edit.blade.php index d3504db..079c585 100644 --- a/resources/views/lexicon/edit.blade.php +++ b/resources/views/lexicon/edit.blade.php @@ -1,12 +1,8 @@ - - - - - - Edit Lexical Entry - - - +@extends('layouts.app') + +@section('title', 'Edit Lexical Entry') + +@section('content')

Edit Lexical Entry

@@ -31,5 +27,4 @@
- - +@endsection diff --git a/resources/views/lexicon/index.blade.php b/resources/views/lexicon/index.blade.php index c2bb005..d3b963a 100644 --- a/resources/views/lexicon/index.blade.php +++ b/resources/views/lexicon/index.blade.php @@ -1,12 +1,8 @@ - - - - - - Lexicon Entries - - - +@extends('layouts.app') + +@section('title', 'Lexicon Entries') + +@section('content')

Lexicon Entries

Add New Entry @@ -20,5 +16,4 @@ @endforeach
- - +@endsection diff --git a/resources/views/lexicon/show.blade.php b/resources/views/lexicon/show.blade.php index 41bff11..9d866e6 100644 --- a/resources/views/lexicon/show.blade.php +++ b/resources/views/lexicon/show.blade.php @@ -1,12 +1,8 @@ - - - - - - {{ $entry->token->text }} - - - +@extends('layouts.app') + +@section('title', $entry->token->text) + +@section('content')
← Back to list

{{ $entry->token->text }} ({{ $entry->language->code }})

@@ -31,7 +27,7 @@
@csrf @method('DELETE') - +
@@ -67,7 +63,7 @@
@csrf @method('DELETE') - +
@@ -79,5 +75,4 @@
- - +@endsection diff --git a/resources/views/tokens/create.blade.php b/resources/views/tokens/create.blade.php index 16b4b61..3db927f 100644 --- a/resources/views/tokens/create.blade.php +++ b/resources/views/tokens/create.blade.php @@ -1,12 +1,8 @@ - - - - - - Add Token - - - +@extends('layouts.app') + +@section('title', 'Add Token') + +@section('content')

Add New Token

@@ -18,5 +14,4 @@
- - +@endsection diff --git a/resources/views/tokens/edit.blade.php b/resources/views/tokens/edit.blade.php index 8c41b12..0cc94f2 100644 --- a/resources/views/tokens/edit.blade.php +++ b/resources/views/tokens/edit.blade.php @@ -1,12 +1,8 @@ - - - - - - Edit Token - - - +@extends('layouts.app') + +@section('title', 'Edit Token') + +@section('content')

Edit Token

@@ -19,5 +15,4 @@
- - +@endsection diff --git a/resources/views/tokens/index.blade.php b/resources/views/tokens/index.blade.php index d259625..68091a6 100644 --- a/resources/views/tokens/index.blade.php +++ b/resources/views/tokens/index.blade.php @@ -1,12 +1,8 @@ - - - - - - Tokens - - - +@extends('layouts.app') + +@section('title', 'Tokens') + +@section('content')

Tokens

Add New Token @@ -22,13 +18,15 @@ @foreach ($tokens as $token) {{ $token->id }} - {{ $token->text }} + + {{ $token->text }} + Edit
@csrf @method('DELETE') - +
@@ -36,5 +34,4 @@
- - +@endsection diff --git a/resources/views/tokens/show.blade.php b/resources/views/tokens/show.blade.php new file mode 100644 index 0000000..0004671 --- /dev/null +++ b/resources/views/tokens/show.blade.php @@ -0,0 +1,22 @@ +@extends('layouts.app') + +@section('title', 'Token: ' . $token->text) + +@section('content') +
+

Token: {{ $token->text }}

+ +

Lexical Entries

+ +
+@endsection diff --git a/resources/views/validate.blade.php b/resources/views/validate.blade.php new file mode 100644 index 0000000..0a9d56a --- /dev/null +++ b/resources/views/validate.blade.php @@ -0,0 +1,104 @@ +@extends('layouts.app') + +@section('title', 'Data Validation') + +@section('content') +
+

Data Validation Results

+ + @if (empty($issues)) + + @else + + + @if (isset($issues['duplicate_tokens'])) +

Case-Insensitive Duplicate Tokens

+ + + + + + + + + @foreach ($issues['duplicate_tokens'] as $token) + + + + + @endforeach + +
IDText
{{ $token->id }}{{ $token->text }}
+ @endif + + @if (isset($issues['duplicate_languages'])) +

Duplicate Language Names

+ + + + + + + + @foreach ($issues['duplicate_languages'] as $language) + + + + @endforeach + +
Name
{{ $language->name }}
+ @endif + + @if (isset($issues['unused_tokens'])) +

Unused Tokens

+ + + + + + + + + @foreach ($issues['unused_tokens'] as $token) + + + + + @endforeach + +
IDText
{{ $token->id }}{{ $token->text }}
+ @endif + + @if (isset($issues['unused_languages'])) +

Unused Languages

+ + + + + + + + + @foreach ($issues['unused_languages'] as $language) + + + + + @endforeach + +
IDName
{{ $language->id }}{{ $language->name }}
+ @endif + + @endif + + +
+@endsection diff --git a/routes/web.php b/routes/web.php index a536575..a027394 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,13 +1,13 @@ route('lexicon.index'); -}); +Route::get('/', [HomeController::class, 'index'])->name('home'); +Route::get('/validate', [HomeController::class, 'validate'])->name('validate'); Route::resources([ 'tokens' => TokenController::class, diff --git a/tests/DuskTestCase.php b/tests/DuskTestCase.php new file mode 100644 index 0000000..020699d --- /dev/null +++ b/tests/DuskTestCase.php @@ -0,0 +1,48 @@ +addArguments(collect([ + $this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080', + '--disable-search-engine-choice-screen', + '--disable-smooth-scrolling', + ])->unless($this->hasHeadlessDisabled(), function (Collection $items) { + return $items->merge([ + '--disable-gpu', + '--headless=new', + ]); + })->all()); + + return RemoteWebDriver::create( + $_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515', + DesiredCapabilities::chrome()->setCapability( + ChromeOptions::CAPABILITY, $options + ) + ); + } +} diff --git a/tests/Feature/DeleteAttributeCommandTest.php b/tests/Feature/DeleteAttributeCommandTest.php new file mode 100644 index 0000000..e37a2cc --- /dev/null +++ b/tests/Feature/DeleteAttributeCommandTest.php @@ -0,0 +1,19 @@ +create(); + + $this->artisan('ote:delete-attribute', ['id' => $attribute->id]) + ->expectsOutput("Attribute with ID '{$attribute->id}' has been deleted.") + ->assertExitCode(0); + + $this->assertDatabaseMissing('attributes', ['id' => $attribute->id]); +}); + +test('the delete attribute command handles non-existent attributes', function () { + $this->artisan('ote:delete-attribute', ['id' => 999]) + ->expectsOutput("Attribute with ID '999' not found.") + ->assertExitCode(1); +}); diff --git a/tests/Feature/DeleteEntryCommandTest.php b/tests/Feature/DeleteEntryCommandTest.php new file mode 100644 index 0000000..11dba1f --- /dev/null +++ b/tests/Feature/DeleteEntryCommandTest.php @@ -0,0 +1,25 @@ +create(); + $attribute = Attribute::factory()->create(['lexical_entry_id' => $entry->id]); + $link = Link::factory()->create(['source_lexical_entry_id' => $entry->id]); + + $this->artisan('ote:delete-entry', ['id' => $entry->id]) + ->expectsOutput("Lexical entry with ID '{$entry->id}' has been deleted.") + ->assertExitCode(0); + + $this->assertDatabaseMissing('lexical_entries', ['id' => $entry->id]); + $this->assertDatabaseMissing('attributes', ['id' => $attribute->id]); + $this->assertDatabaseMissing('links', ['id' => $link->id]); +}); + +test('the delete entry command handles non-existent entries', function () { + $this->artisan('ote:delete-entry', ['id' => 999]) + ->expectsOutput("Lexical entry with ID '999' not found.") + ->assertExitCode(1); +}); diff --git a/tests/Feature/DeleteLanguageCommandTest.php b/tests/Feature/DeleteLanguageCommandTest.php new file mode 100644 index 0000000..297a09e --- /dev/null +++ b/tests/Feature/DeleteLanguageCommandTest.php @@ -0,0 +1,22 @@ +create(); + $language = $entry->language; + + $this->artisan('ote:delete-language', ['language' => $language->code]) + ->expectsOutput("Language '{$language->code}' and its associated lexical entries have been deleted.") + ->assertExitCode(0); + + $this->assertDatabaseMissing('languages', ['id' => $language->id]); + $this->assertDatabaseMissing('lexical_entries', ['id' => $entry->id]); +}); + +test('the delete language command handles non-existent languages', function () { + $this->artisan('ote:delete-language', ['language' => 'non-existent-language']) + ->expectsOutput("Language 'non-existent-language' not found.") + ->assertExitCode(1); +}); diff --git a/tests/Feature/DeleteLinkCommandTest.php b/tests/Feature/DeleteLinkCommandTest.php new file mode 100644 index 0000000..68ad97a --- /dev/null +++ b/tests/Feature/DeleteLinkCommandTest.php @@ -0,0 +1,19 @@ +create(); + + $this->artisan('ote:delete-link', ['id' => $link->id]) + ->expectsOutput("Link with ID '{$link->id}' has been deleted.") + ->assertExitCode(0); + + $this->assertDatabaseMissing('links', ['id' => $link->id]); +}); + +test('the delete link command handles non-existent links', function () { + $this->artisan('ote:delete-link', ['id' => 999]) + ->expectsOutput("Link with ID '999' not found.") + ->assertExitCode(1); +}); diff --git a/tests/Feature/DeleteTokenCommandTest.php b/tests/Feature/DeleteTokenCommandTest.php new file mode 100644 index 0000000..c8a33b6 --- /dev/null +++ b/tests/Feature/DeleteTokenCommandTest.php @@ -0,0 +1,22 @@ +create(); + $token = $entry->token; + + $this->artisan('ote:delete-token', ['token' => $token->text]) + ->expectsOutput("Token '{$token->text}' and its associated lexical entries have been deleted.") + ->assertExitCode(0); + + $this->assertDatabaseMissing('tokens', ['id' => $token->id]); + $this->assertDatabaseMissing('lexical_entries', ['id' => $entry->id]); +}); + +test('the delete token command handles non-existent tokens', function () { + $this->artisan('ote:delete-token', ['token' => 'non-existent-token']) + ->expectsOutput("Token 'non-existent-token' not found.") + ->assertExitCode(1); +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8e57a46..3eece2d 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -3,10 +3,13 @@ namespace Tests\Feature; // use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class ExampleTest extends TestCase { + use RefreshDatabase; + /** * A basic test example. */ @@ -14,6 +17,6 @@ public function test_the_application_returns_a_successful_response(): void { $response = $this->get('/'); - $response->assertStatus(302); + $response->assertStatus(200); } } diff --git a/tests/Feature/HomeControllerTest.php b/tests/Feature/HomeControllerTest.php new file mode 100644 index 0000000..83c4008 --- /dev/null +++ b/tests/Feature/HomeControllerTest.php @@ -0,0 +1,57 @@ +count(5)->create(); + Language::factory()->count(3)->create(); + + $response = $this->get('/'); + + $response->assertStatus(200); + $response->assertSee('Open Translation Engine v2'); + $response->assertSee('Lexicon Statistics'); + $response->assertSee('Tokens'); + $response->assertSee('5'); + $response->assertSee('Languages'); + $response->assertSee('3'); + $response->assertSee('Manage Tokens'); + $response->assertSee('Manage Languages'); + $response->assertSee('Manage Lexical Entries'); + $response->assertSee('Validate Data Integrity'); + } + + public function test_the_validation_page_loads_correctly_with_no_issues() + { + $response = $this->get('/validate'); + + $response->assertStatus(200); + $response->assertSee('Data Validation Results'); + $response->assertSee('All good!'); + } + + public function test_the_validation_page_shows_issues() + { + Token::factory()->create(['text' => 'apple']); + Token::factory()->create(['text' => 'Apple']); + + $response = $this->get('/validate'); + + $response->assertStatus(200); + $response->assertSee('Data Validation Results'); + $response->assertSee('Issues found!'); + $response->assertSee('Case-Insensitive Duplicate Tokens'); + $response->assertSee('apple'); + $response->assertSee('Apple'); + } +} diff --git a/tests/Feature/LanguageControllerTest.php b/tests/Feature/LanguageControllerTest.php index a40b2db..d9d16b0 100644 --- a/tests/Feature/LanguageControllerTest.php +++ b/tests/Feature/LanguageControllerTest.php @@ -1,6 +1,7 @@ count(3)->create(); @@ -40,3 +41,14 @@ $response->assertRedirect('/languages'); $this->assertDatabaseMissing('languages', ['id' => $language->id]); }); + +test('it shows a language and its lexical entries', function () { + $entry = LexicalEntry::factory()->create(); + $language = $entry->language; + + $response = $this->get('/languages/'.$language->id); + + $response->assertStatus(200); + $response->assertSee($language->name); + $response->assertSee($entry->token->text); +}); diff --git a/tests/Feature/ListAttributesCommandTest.php b/tests/Feature/ListAttributesCommandTest.php new file mode 100644 index 0000000..97860e7 --- /dev/null +++ b/tests/Feature/ListAttributesCommandTest.php @@ -0,0 +1,18 @@ +create(); + $attribute2 = Attribute::factory()->create(); + + $this->artisan('ote:list-attributes') + ->expectsTable( + ['ID', 'Lexical Entry ID', 'Key', 'Value'], + [ + [$attribute1->id, $attribute1->lexical_entry_id, $attribute1->key, $attribute1->value], + [$attribute2->id, $attribute2->lexical_entry_id, $attribute2->key, $attribute2->value], + ] + ) + ->assertExitCode(0); +}); diff --git a/tests/Feature/ListLanguagesCommandTest.php b/tests/Feature/ListLanguagesCommandTest.php new file mode 100644 index 0000000..fdda1e6 --- /dev/null +++ b/tests/Feature/ListLanguagesCommandTest.php @@ -0,0 +1,18 @@ +create(); + $language2 = Language::factory()->create(); + + $this->artisan('ote:list-languages') + ->expectsTable( + ['ID', 'Code', 'Name'], + [ + [$language1->id, $language1->code, $language1->name], + [$language2->id, $language2->code, $language2->name], + ] + ) + ->assertExitCode(0); +}); diff --git a/tests/Feature/ListLinksCommandTest.php b/tests/Feature/ListLinksCommandTest.php new file mode 100644 index 0000000..daf8446 --- /dev/null +++ b/tests/Feature/ListLinksCommandTest.php @@ -0,0 +1,18 @@ +create(); + $link2 = Link::factory()->create(); + + $this->artisan('ote:list-links') + ->expectsTable( + ['ID', 'Source Entry ID', 'Target Entry ID', 'Type'], + [ + [$link1->id, $link1->source_lexical_entry_id, $link1->target_lexical_entry_id, $link1->type], + [$link2->id, $link2->source_lexical_entry_id, $link2->target_lexical_entry_id, $link2->type], + ] + ) + ->assertExitCode(0); +}); diff --git a/tests/Feature/ListTokensCommandTest.php b/tests/Feature/ListTokensCommandTest.php new file mode 100644 index 0000000..e062e69 --- /dev/null +++ b/tests/Feature/ListTokensCommandTest.php @@ -0,0 +1,13 @@ +create(); + $token2 = Token::factory()->create(); + + $this->artisan('ote:list-tokens') + ->expectsOutputToContain($token1->text) + ->expectsOutputToContain($token2->text) + ->assertExitCode(0); +}); diff --git a/tests/Feature/ShowEntryCommandTest.php b/tests/Feature/ShowEntryCommandTest.php new file mode 100644 index 0000000..72db7a3 --- /dev/null +++ b/tests/Feature/ShowEntryCommandTest.php @@ -0,0 +1,28 @@ +create(); + $attribute = Attribute::factory()->create(['lexical_entry_id' => $entry->id]); + $link = Link::factory()->create(['source_lexical_entry_id' => $entry->id]); + + $this->artisan('ote:show-entry', ['id' => $entry->id]) + ->expectsOutput("Lexical Entry Details:") + ->expectsOutput(" ID: {$entry->id}") + ->expectsOutput(" Token: {$entry->token->text}") + ->expectsOutput(" Language: {$entry->language->name}") + ->expectsOutput("Attributes:") + ->expectsTable(['Key', 'Value'], [[$attribute->key, $attribute->value]]) + ->expectsOutput("Links (Source):") + ->expectsTable(['Target Entry ID', 'Target Token', 'Type'], [[$link->target_lexical_entry_id, $link->targetEntry->token->text, $link->type]]) + ->assertExitCode(0); +}); + +test('the show entry command handles non-existent entries', function () { + $this->artisan('ote:show-entry', ['id' => 999]) + ->expectsOutput("Lexical entry with ID '999' not found.") + ->assertExitCode(1); +}); diff --git a/tests/Feature/ShowLanguageCommandTest.php b/tests/Feature/ShowLanguageCommandTest.php new file mode 100644 index 0000000..fd4c975 --- /dev/null +++ b/tests/Feature/ShowLanguageCommandTest.php @@ -0,0 +1,29 @@ +create(); + $language = $entry->language; + + $this->artisan('ote:show-language', ['id' => $language->id]) + ->expectsOutput("Language Details:") + ->expectsOutput(" ID: {$language->id}") + ->expectsOutput(" Code: {$language->code}") + ->expectsOutput(" Name: {$language->name}") + ->expectsOutput("Lexical Entries:") + ->expectsTable( + ['Entry ID', 'Token'], + [ + [$entry->id, $entry->token->text], + ] + ) + ->assertExitCode(0); +}); + +test('the show language command handles non-existent languages', function () { + $this->artisan('ote:show-language', ['id' => 999]) + ->expectsOutput("Language with ID '999' not found.") + ->assertExitCode(1); +}); diff --git a/tests/Feature/ShowTokenCommandTest.php b/tests/Feature/ShowTokenCommandTest.php new file mode 100644 index 0000000..40b3d5e --- /dev/null +++ b/tests/Feature/ShowTokenCommandTest.php @@ -0,0 +1,28 @@ +create(); + $token = $entry->token; + + $this->artisan('ote:show-token', ['id' => $token->id]) + ->expectsOutput("Token Details:") + ->expectsOutput(" ID: {$token->id}") + ->expectsOutput(" Text: {$token->text}") + ->expectsOutput("Lexical Entries:") + ->expectsTable( + ['Entry ID', 'Language'], + [ + [$entry->id, $entry->language->name], + ] + ) + ->assertExitCode(0); +}); + +test('the show token command handles non-existent tokens', function () { + $this->artisan('ote:show-token', ['id' => 999]) + ->expectsOutput("Token with ID '999' not found.") + ->assertExitCode(1); +}); diff --git a/tests/Feature/StatsCommandTest.php b/tests/Feature/StatsCommandTest.php new file mode 100644 index 0000000..209664b --- /dev/null +++ b/tests/Feature/StatsCommandTest.php @@ -0,0 +1,26 @@ +count(5)->create(); + Language::factory()->count(3)->create(); + LexicalEntry::factory()->count(10)->create(); + Attribute::factory()->count(15)->create(); + Link::factory()->count(20)->create(); + + $this->artisan('ote:stats') + ->expectsOutputToContain('Lexicon Statistics') + ->expectsOutputToContain('Tokens') + ->expectsOutputToContain('Languages') + ->expectsOutputToContain('Lexical Entries') + ->expectsOutputToContain('Attributes') + ->expectsOutputToContain('Links') + ->expectsOutputToContain('Entries per language') + ->assertExitCode(0); +}); diff --git a/tests/Feature/TokenControllerTest.php b/tests/Feature/TokenControllerTest.php index ce87f9c..879a342 100644 --- a/tests/Feature/TokenControllerTest.php +++ b/tests/Feature/TokenControllerTest.php @@ -1,6 +1,7 @@ count(3)->create(); @@ -40,3 +41,14 @@ $response->assertRedirect('/tokens'); $this->assertDatabaseMissing('tokens', ['id' => $token->id]); }); + +test('it shows a token and its lexical entries', function () { + $entry = LexicalEntry::factory()->create(); + $token = $entry->token; + + $response = $this->get('/tokens/'.$token->id); + + $response->assertStatus(200); + $response->assertSee($token->text); + $response->assertSee($entry->language->name); +}); diff --git a/tests/Feature/UpdateLanguageCommandTest.php b/tests/Feature/UpdateLanguageCommandTest.php new file mode 100644 index 0000000..01a1d08 --- /dev/null +++ b/tests/Feature/UpdateLanguageCommandTest.php @@ -0,0 +1,28 @@ +create(['name' => 'Old Name']); + + $this->artisan('ote:update-language', [ + 'id' => $language->id, + 'new_name' => 'New Name' + ]) + ->expectsOutput("Language with ID '{$language->id}' has been updated to 'New Name'.") + ->assertExitCode(0); + + $this->assertDatabaseHas('languages', [ + 'id' => $language->id, + 'name' => 'New Name', + ]); +}); + +test('the update language command handles non-existent languages', function () { + $this->artisan('ote:update-language', [ + 'id' => 999, + 'new_name' => 'New Name' + ]) + ->expectsOutput("Language with ID '999' not found.") + ->assertExitCode(1); +}); diff --git a/tests/Feature/UpdateTokenCommandTest.php b/tests/Feature/UpdateTokenCommandTest.php new file mode 100644 index 0000000..5b11ca5 --- /dev/null +++ b/tests/Feature/UpdateTokenCommandTest.php @@ -0,0 +1,28 @@ +create(['text' => 'old-text']); + + $this->artisan('ote:update-token', [ + 'id' => $token->id, + 'new_text' => 'new-text' + ]) + ->expectsOutput("Token with ID '{$token->id}' has been updated to 'new-text'.") + ->assertExitCode(0); + + $this->assertDatabaseHas('tokens', [ + 'id' => $token->id, + 'text' => 'new-text', + ]); +}); + +test('the update token command handles non-existent tokens', function () { + $this->artisan('ote:update-token', [ + 'id' => 999, + 'new_text' => 'new-text' + ]) + ->expectsOutput("Token with ID '999' not found.") + ->assertExitCode(1); +}); diff --git a/tests/Feature/ValidateCommandTest.php b/tests/Feature/ValidateCommandTest.php new file mode 100644 index 0000000..472a25c --- /dev/null +++ b/tests/Feature/ValidateCommandTest.php @@ -0,0 +1,47 @@ +artisan('ote:validate') + ->expectsOutput('Starting validation...') + ->expectsOutput('Validation complete. No issues found.') + ->assertExitCode(0); +}); + +test('the validate command finds duplicate tokens', function () { + $token1 = Token::factory()->create(['text' => 'apple']); + $token2 = Token::factory()->create(['text' => 'Apple']); + + $this->artisan('ote:validate') + ->expectsOutputToContain('Starting validation...') + ->expectsOutputToContain('Found case-insensitive duplicate tokens:') + ->expectsOutputToContain('Apple') + ->expectsOutputToContain('apple') + ->expectsOutputToContain('Validation complete. Issues found.') + ->assertExitCode(0); +}); + +test('the validate command finds unused tokens', function () { + $token = Token::factory()->create(); + + $this->artisan('ote:validate') + ->expectsOutput('Starting validation...') + ->expectsOutput('Found unused tokens:') + ->expectsTable(['ID', 'Text'], [[$token->id, $token->text]]) + ->expectsOutput('Validation complete. Issues found.') + ->assertExitCode(0); +}); + +test('the validate command finds unused languages', function () { + $language = Language::factory()->create(); + + $this->artisan('ote:validate') + ->expectsOutput('Starting validation...') + ->expectsOutput('Found unused languages:') + ->expectsTable(['ID', 'Name'], [[$language->id, $language->name]]) + ->expectsOutput('Validation complete. Issues found.') + ->assertExitCode(0); +}); diff --git a/tests/Pest.php b/tests/Pest.php index fac6863..6ceca99 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,10 @@ in('Browser'); + use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase;