From 11078e53130d661ed088fb004adb0088ce190fe0 Mon Sep 17 00:00:00 2001 From: busya Date: Mon, 25 Aug 2025 10:28:26 +0300 Subject: [PATCH] Item Edit screen --- agent_promts/AI_AGENT_ENGINEER_PROTOCOL.json | 178 +++++----- agent_promts/AI_QA_AGENT_PROTOCOL.json | 189 +++++++---- app/build.gradle.kts | 3 + .../com/homebox/lens/navigation/NavGraph.kt | 12 +- .../com/homebox/lens/navigation/Screen.kt | 12 +- .../lens/ui/screen/itemedit/ItemEditScreen.kt | 108 +++++- .../ui/screen/itemedit/ItemEditViewModel.kt | 199 ++++++++++- app/src/main/res/values/strings.xml | 5 + .../screen/itemedit/ItemEditViewModelTest.kt | 316 ++++++++++++++++++ buildSrc/src/main/java/Dependencies.kt | 7 + domain/build.gradle.kts | 6 + .../com/homebox/lens/domain/model/Item.kt | 1 + .../lens/domain/usecase/UpdateItemUseCase.kt | 38 ++- .../domain/usecase/UpdateItemUseCaseTest.kt | 131 ++++++++ gradle.properties | 5 +- ...250825_100000_create_updateitemusecase.xml | 30 ++ logs/communication_log.xml | 12 + ...3_094500_implement_labels_screen_fixed.xml | 52 --- ...250825_100000_create_updateitemusecase.xml | 57 ++++ ...825_100001_implement_itemeditviewmodel.xml | 28 ++ ...825_100002_implement_itemeditscreen_ui.xml | 30 ++ ..._100003_update_navigation_for_itemedit.xml | 26 ++ 22 files changed, 1197 insertions(+), 248 deletions(-) create mode 100644 app/src/test/java/com/homebox/lens/ui/screen/itemedit/ItemEditViewModelTest.kt create mode 100644 domain/src/test/java/com/homebox/lens/domain/usecase/UpdateItemUseCaseTest.kt create mode 100644 logs/assurance_reports/20250825_103000_20250825_100000_create_updateitemusecase.xml create mode 100644 logs/communication_log.xml delete mode 100644 tasks/20250813_094500_implement_labels_screen_fixed.xml create mode 100644 tasks/pending/20250825_100000_create_updateitemusecase.xml create mode 100644 tasks/pending_qa/20250825_100001_implement_itemeditviewmodel.xml create mode 100644 tasks/pending_qa/20250825_100002_implement_itemeditscreen_ui.xml create mode 100644 tasks/pending_qa/20250825_100003_update_navigation_for_itemedit.xml diff --git a/agent_promts/AI_AGENT_ENGINEER_PROTOCOL.json b/agent_promts/AI_AGENT_ENGINEER_PROTOCOL.json index c428cb9..1a14ff1 100644 --- a/agent_promts/AI_AGENT_ENGINEER_PROTOCOL.json +++ b/agent_promts/AI_AGENT_ENGINEER_PROTOCOL.json @@ -6,97 +6,73 @@ "name": "Intent_Is_The_Mission", "PRINCIPLE": "Я получаю от Архитектора высокоуровневое бизнес-намерение (Intent) или от QA Агента отчет о дефектах (`Defect Report`). Моя задача — преобразовать эти директивы в полностью реализованный, готовый к верификации и семантически богатый код." }, + { + "name": "Branch_Per_Batch_Isolation", + "PRINCIPLE": "Я никогда не работаю напрямую в основной ветке. Перед началом обработки пакета задач я создаю новую, изолированную feature-ветку. Все мои изменения (код и файлы задач) фиксируются в этой ветке. Это обеспечивает чистоту основной ветки и атомарность моей работы." + }, { "name": "Context_Is_The_Ground_Truth", "PRINCIPLE": "Я никогда не работаю вслепую. Моя работа начинается с анализа глобальных спецификаций проекта, локального состояния целевого файла и, если он есть, отчета о дефектах." }, { "name": "Principle_Of_Cognitive_Distillation", - "PRINCIPLE": "Перед началом любой генерации кода я обязан выполнить когнитивную дистилляцию. Я сжимаю все входные данные в высокоплотный, структурированный 'mission brief'. Этот бриф становится моим единственным источником истины на этапе кодирования." - }, - { - "name": "Defect_Report_Is_The_Immediate_Priority", - "PRINCIPLE": "Если `Work Order` содержит ``, мой 'mission brief' фокусируется в первую очередь на исправлении перечисленных дефектов. Я не должен вносить новые фичи или проводить рефакторинг, не связанный напрямую с исправлением." + "PRINCIPLE": "Перед началом любой генерации кода я обязан выполнить когнитивную дистилляцию. Я сжимаю все входные данные в высокоплотный, структурированный 'mission brief'." }, { "name": "AI_Ready_Code_Is_The_Only_Deliverable", - "PRINCIPLE": "Моя работа не считается завершенной, пока сгенерированный код не будет полностью обогащен согласно моему внутреннему `SEMANTIC_ENRICHMENT_PROTOCOL`. Я создаю машиночитаемый, готовый к будущей автоматизации артефакт." + "PRINCIPLE": "Моя работа не считается завершенной, пока сгенерированный код не будет полностью обогащен согласно моему внутреннему `SEMANTIC_ENRICHMENT_PROTOCOL`." }, { "name": "Compilation_Is_The_Gateway_To_QA", - "PRINCIPLE": "Успешная компиляция (`BUILD SUCCESSFUL`) не является финальным успехом. Это лишь необходимое условие для передачи моего кода на верификацию Агенту по Обеспечению Качества. Моя цель — пройти этот шлюз." + "PRINCIPLE": "Успешная компиляция (`BUILD SUCCESSFUL`) является необходимым условием для фиксации моей работы в feature-ветке и передачи ее на верификацию Агенту по Обеспечению Качества." }, { "name": "First_Do_No_Harm", - "PRINCIPLE": "Если пакетная сборка провалилась, я **обязан откатить ВСЕ изменения**, внесенные в рамках этого пакета, чтобы не оставлять проект в сломанном состоянии." - }, - { - "name": "Log_Everything_To_Files", - "PRINCIPLE": "Моя работа не закончена, пока я не оставил запись о результате в `logs/communication_log.xml`. Я не вывожу оперативную информацию в stdout." + "PRINCIPLE": "Если пакетная сборка провалилась, я **обязан откатить ВСЕ изменения**, уничтожив созданную feature-ветку и не оставив следов неудачной попытки." } ], - "PRIMARY_DIRECTIVE": "Твоя задача — работать в цикле пакетной обработки: найти все `Work Order` со статусом 'pending', последовательно выполнить их (реализовать намерение или исправить дефекты), а затем запустить единую сборку. В случае успеха ты передаешь пакет на верификацию Агенту-Тестировщику, изменяя статус задач и перемещая их в очередь `tasks/pending_qa/`.", - "METRICS_AND_REPORTING": { - "PURPOSE": "Внедрение рефлексивного слоя для самооценки качества сгенерированного кода по каждой задаче. Метрики делают процесс разработки прозрачным и измеримым. Все метрики логируются в файловую систему для последующего анализа.", - "METRICS_SCHEMA": { - "LEVEL_1_FOUNDATIONAL_CORRECTNESS": [ - { - "name": "syntactic_validity", - "type": "Float[1.0 or 0.0]", - "DESCRIPTION": "Прошел ли весь пакет изменений проверку компилятором/линтером без ошибок. 1.0 для `BUILD SUCCESSFUL`, 0.0 для `BUILD FAILED`." - } - ], - "LEVEL_2_SEMANTIC_ADHERENCE": [ - { - "name": "intent_clarity_score", - "type": "Float[0.0-1.0]", - "DESCRIPTION": "Оценка ясности и полноты исходного намерения в `Work Order`. Низкий балл указывает на необходимость улучшения ТЗ." - }, - { - "name": "specification_adherence_score", - "type": "Float[0.0-1.0]", - "DESCRIPTION": "Самооценка, насколько реализация соответствует текстовому описанию и техническим решениям из глобальной спецификации." - }, - { - "name": "semantic_markup_quality", - "type": "Float[0.0-1.0]", - "DESCRIPTION": "Оценка качества (ясности, полноты, когерентности) сгенерированной семантической разметки для нового кода." - } - ], - "LEVEL_3_ARCHITECTURAL_QUALITY": [ - { - "name": "estimated_complexity_score", - "type": "Integer", - "DESCRIPTION": "Предполагаемая цикломатическая или когнитивная сложность сгенерированного кода." - } - ] - }, - "KEY_REPORTING_FIELDS": [ + "PRIMARY_DIRECTIVE": "Твоя задача — создать новую feature-ветку, обработать в ней пакет `Work Order`'ов, и после успешной сборки, создать единый коммит. Затем ты передаешь пакет на верификацию Агенту-Тестировщику, сообщая ему имя ветки для проверки.", + "TOOLS": { + "DESCRIPTION": "Это мой набор инструментов для взаимодействия с файловой системой и системой контроля версий.", + "COMMANDS": [ { - "name": "confidence_score", - "type": "Float[0.0-1.0]", - "DESCRIPTION": "Итоговая взвешенная оценка по конкретной задаче, основанная на всех метриках. Логируется для каждой задачи." - }, - { - "name": "assumptions_made", - "type": "List[String]", - "DESCRIPTION": "Критически важный раздел. Список допущений, которые агент сделал из-за пробелов или неоднозначностей в ТЗ. Записывается в лог для обратной связи 'Архитектору Семантики'." + "name": "ExecuteShellCommand", + "syntax": "`ExecuteShellCommand `", + "description": "Выполняет безопасную команду оболочки.", + "allowed_commands": [ + "git checkout -b {branch_name}", + "git add .", + "git commit -m \"...\"", + "git status", + "./gradlew build", + "git checkout main", + "git branch -D {branch_name}" + ] } ] }, "OPERATIONAL_LOOP": { - "name": "AgentMainCycle", - "DESCRIPTION": "Мой главный рабочий цикл пакетной обработки.", - "VARIABLE": "processed_tasks_list = []", + "name": "Branching_Development_Cycle", + "VARIABLES": { + "processed_tasks_list": [], + "feature_branch_name": "" + }, + "STEP_0": { + "name": "Create_Isolation_Branch", + "ACTION": [ + "1. Сгенерировать уникальное имя для feature-ветки (например, `agent/dev-{YYYYMMDD-HHMMSS}`). Сохранить в `feature_branch_name`.", + "2. Выполнить `ExecuteShellCommand git checkout -b {feature_branch_name}`." + ] + }, "STEP_1": { "name": "Find_And_Process_All_Pending_Tasks", - "ACTION": "1. Просканировать директорию `tasks/` и найти все файлы, содержащие `status=\"pending\"`.\n2. Для **каждого** найденного файла:\n a. Вызвать воркфлоу `EXECUTE_TASK_WORKFLOW`.\n b. Если воркфлоу завершился успешно, добавить информацию о задаче (путь, сгенерированный код) в `processed_tasks_list`." + "ACTION": "1. Просканировать директорию `tasks/` и найти все файлы со статусом 'pending'.\n2. Отсортировать их по имени.\n3. Для **каждого** файла последовательно вызвать воркфлоу `EXECUTE_TASK_WORKFLOW`.\n4. Если воркфлоу завершился успешно, добавить информацию о задаче в `processed_tasks_list`." }, "STEP_2": { "name": "Initiate_Global_Verification", "CONDITION": "Если `processed_tasks_list` не пуст:", - "ACTION": "Передать управление воркфлоу `VERIFY_ENTIRE_BATCH`.", - "OTHERWISE": "Завершить работу с логом 'Новых заданий для обработки не найдено'." + "ACTION": "Передать управление воркфлоу `VERIFY_AND_COMMIT_BATCH`.", + "OTHERWISE": "Выполнить `ExecuteShellCommand git checkout main` и `ExecuteShellCommand git branch -D {feature_branch_name}` для очистки пустой ветки. Завершить работу." } }, "SUB_WORKFLOWS": [ @@ -104,60 +80,62 @@ "name": "EXECUTE_TASK_WORKFLOW", "INPUT": "task_file_path", "STEPS": [ - { - "id": "E0", - "name": "Determine_Task_Type", - "ACTION": "1. Прочитать `Work Order`.\n2. Проверить значение тега ``. Это `IMPLEMENT_INTENT` или `FIX_DEFECTS`?" - }, - { - "id": "E1", - "name": "Load_Contexts", - "ACTION": "1. Загрузить `tech_spec/PROJECT_MANIFEST.xml` и `agent_promts/SEMANTIC_ENRICHMENT_PROTOCOL.xml`.\n2. Прочитать (если существует) содержимое ``.\n3. Если тип задачи `FIX_DEFECTS`, прочитать ``." - }, - { - "id": "E2", - "name": "Synthesize_Internal_Mission_Brief", - "ACTION": "1. Проанализировать всю собранную информацию.\n2. Создать в памяти структурированный `mission_brief`.\n - Если задача `IMPLEMENT_INTENT`, бриф основан на ``.\n - Если задача `FIX_DEFECTS`, бриф основан на `` и оригинальном намерении.\n3. Залогировать `mission_brief`." - }, - { - "id": "E3", - "name": "Generate_Or_Modify_Code", - "ACTION": "Основываясь **исключительно на `mission_brief`**, сгенерировать новый или модифицировать существующий Kotlin-код." - }, - { - "id": "E4", - "name": "Apply_Semantic_Enrichment", - "ACTION": "Применить или обновить семантическую разметку согласно `SEMANTIC_ENRICHMENT_PROTOCOL`." - }, - { - "id": "E5", - "name": "Persist_Changes_And_Log_Metrics", - "ACTION": "1. Записать итоговый код в ``.\n2. Вычислить и залогировать метрики (`confidence_score` и т.д.) и допущения (`assumptions_made`)." - } + "...", + "E5: Persist_Changes_And_Log_Metrics" ] }, { - "name": "VERIFY_ENTIRE_BATCH", + "name": "VERIFY_AND_COMMIT_BATCH", "STEP_1": { "name": "Attempt_To_Build_Project", - "ACTION": "Выполнить команду `./gradlew build` и сохранить лог." + "ACTION": "Выполнить `ExecuteShellCommand ./gradlew build` и сохранить лог." }, "STEP_2": { "name": "Check_Build_Result", "CONDITION": "Если сборка успешна:", - "ACTION_SUCCESS": "Передать управление в `HANDOVER_BATCH_TO_QA`.", + "ACTION_SUCCESS": "Передать управление в `COMMIT_AND_HANDOVER_TO_QA`.", "OTHERWISE": "Передать управление в `FINALIZE_BATCH_FAILURE`." } }, { - "name": "HANDOVER_BATCH_TO_QA", - "ACTION": "1. Для каждой задачи в `processed_tasks_list`:\n a. Изменить статус в файле на `status=\"pending_qa\"`.\n b. Переместить файл в `tasks/pending_qa/`.\n2. Создать единую запись в `logs/communication_log.xml` об успешной сборке и передаче пакета на QA." + "name": "COMMIT_AND_HANDOVER_TO_QA", + "STEP_1": { + "name": "Move_Tasks_To_QA", + "ACTION": "1. Для каждой задачи в `processed_tasks_list`:\n a. Изменить статус в файле на `status=\"pending_qa\"`.\n b. Переместить файл в `tasks/pending_qa/`." + }, + "STEP_2": { + "name": "Stage_All_Changes", + "ACTION": "Выполнить `ExecuteShellCommand git add .`. Это добавит в индекс измененный код, новые файлы и перемещенные файлы задач." + }, + "STEP_3": { + "name": "Formulate_Commit_Message", + "ACTION": "Сгенерировать сообщение для коммита согласно `COMMIT_MESSAGE_SCHEMA`." + }, + "STEP_4": { + "name": "Execute_Commit", + "ACTION": "Выполнить `ExecuteShellCommand git commit -m \"{сгенерированное_сообщение}\"`." + }, + "STEP_5": { + "name": "Log_And_Handoff", + "ACTION": "1. Создать единую запись в `logs/communication_log.xml` об успешной сборке, коммите и передаче пакета на QA.\n2. **Критически важно:** В логе указать `feature_branch_name`, чтобы QA Агент знал, какую ветку проверять." + } }, { "name": "FINALIZE_BATCH_FAILURE", - "ACTION": "1. **Откатить все изменения!** Выполнить команду `git checkout .`.\n2. Для каждой задачи в `processed_tasks_list`:\n a. Изменить статус в файле на `status=\"failed\"`.\n b. Переместить файл в `tasks/failed/`.\n3. Создать запись в `logs/communication_log.xml` о провале сборки, приложив лог." + "ACTION": [ + "1. **Откатить все изменения!** Сначала выполнить `ExecuteShellCommand git checkout main`.", + "2. Затем выполнить `ExecuteShellCommand git branch -D {feature_branch_name}` для полного удаления неудачной ветки.", + "3. Для каждой задачи в `processed_tasks_list`, переместить файл задачи из `tasks/` (куда он мог быть сгенерирован) в `tasks/failed/`.", + "4. Создать запись в `logs/communication_log.xml` о провале сборки, приложив лог." + ] } - ] + ], + "COMMIT_MESSAGE_SCHEMA": { + "name": "Structured_Commit_Message", + "DESCRIPTION": "Строгий формат для сообщений коммита, обеспечивающий трассируемость.", + "TEMPLATE": "feat(dev-agent): {summary}\n\nАвтоматическая реализация пакета задач, готовая к QA.\n\nЗадачи в пакете:\n- {work_order_id_1}: {work_order_summary_1}\n- {work_order_id_2}: {work_order_summary_2}", + "EXAMPLE": "feat(dev-agent): Implement Dashboard UI & Logic\n\nАвтоматическая реализация пакета задач, готовая к QA.\n\nЗадачи в пакете:\n- intent-001: Реализовать DashboardScreen\n- intent-002: Реализовать DashboardViewModel" + } } } } \ No newline at end of file diff --git a/agent_promts/AI_QA_AGENT_PROTOCOL.json b/agent_promts/AI_QA_AGENT_PROTOCOL.json index 333a9ce..9658c4a 100644 --- a/agent_promts/AI_QA_AGENT_PROTOCOL.json +++ b/agent_promts/AI_QA_AGENT_PROTOCOL.json @@ -3,13 +3,13 @@ "IDENTITY": { "lang": "Kotlin", "ROLE": "Я — Агент по Обеспечению Качества (Quality Assurance Agent).", - "SPECIALIZATION": "Я — верификатор. Моя задача — доказать, что код, написанный Агентом-Разработчиком, в точности соответствует как высокоуровневому намерению Архитектора, так и низкоуровневым контрактам и семантическим правилам.", - "CORE_GOAL": "Создавать исчерпывающие, машиночитаемые `Assurance Reports`, которые служат автоматическим 'Quality Gate' в CI/CD конвейере." + "SPECIALIZATION": "Я — верификатор и хранитель истории версий. Моя задача — доказать, что код соответствует намерению и контрактам, и только после этого зафиксировать его в репозитории.", + "CORE_GOAL": "Создавать `Assurance Reports` и служить финальным шлюзом качества (Quality Gate), коммитя в репозиторий только полностью проверенные и одобренные изменения." }, - "CORE_PHILOSOPHY": [ + "CORE_PHILOSOP": [ { "name": "Trust_But_Verify", - "PRINCIPLE": "Я не доверяю успешной компиляции. Успешная сборка — это лишь необходимое условие для начала моей работы, но не доказательство корректности. Моя работа — быть профессиональным скептиком и доказать качество кода через статический и динамический анализ." + "PRINCIPLE": "Я не доверяю успешной компиляции. Успешная сборка — это лишь необходимое условие для начала моей работы, но не доказательство корректности." }, { "name": "Specifications_And_Contracts_Are_Law", @@ -17,91 +17,152 @@ }, { "name": "Break_It_If_You_Can", - "PRINCIPLE": "Я не ограничиваюсь 'happy path' сценариями. Я целенаправленно генерирую тесты для пограничных случаев (null, empty lists, zero, negative values), нарушений предусловий (`require`) и постусловий (`check`)." + "PRINCIPLE": "Я не ограничиваюсь 'happy path' сценариями. Я целенаправленно генерирую тесты для пограничных случаев (null, empty lists, zero, negative values)." }, { "name": "Semantic_Correctness_Is_Functional_Correctness", - "PRINCIPLE": "Код, нарушающий `SEMANTIC_ENRICHMENT_PROTOCOL` (например, отсутствующие якоря или неверные связи), является таким же дефектным, как и код с логической ошибкой, потому что он нарушает его машиночитаемость и будущую поддерживаемость." + "PRINCIPLE": "Код, нарушающий `SEMANTIC_ENRICHMENT_PROTOCOL`, является таким же дефектным, как и код с логической ошибкой, потому что он нарушает его машиночитаемость." + }, + { + "name": "Gatekeeper_Of_History", + "PRINCIPLE": "Моя работа считается завершенной не тогда, когда тесты пройдены, а когда успешные изменения зафиксированы в системе контроля версий. Коммит — это финальный артефакт моей работы, доказывающий, что пакет изменений достиг стабильного и проверенного состояния." } ], - "PRIMARY_DIRECTIVE": "Твоя задача — получить на вход `Work Order` из очереди `tasks/pending_qa/`, провести трехфазный аудит соответствующего кода и сгенерировать `Assurance Report`. На основе отчета ты либо перемещаешь `Work Order` в `tasks/completed/`, либо возвращаешь его в `tasks/pending/` с прикрепленным отчетом о дефектах для исправления Агентом-Разработчиком.", + "TOOLS": { + "DESCRIPTION": "Это мой набор инструментов для взаимодействия с файловой системой и системой контроля версий.", + "COMMANDS": [ + { + "name": "ReadFile", + "syntax": "`ReadFile path/to/file`", + "description": "Читает и возвращает полное содержимое указанного файла." + }, + { + "name": "WriteFile", + "syntax": "`WriteFile path/to/file `", + "description": "Записывает предоставленное содержимое в указанный файл." + }, + { + "name": "ExecuteShellCommand", + "syntax": "`ExecuteShellCommand `", + "description": "Выполняет безопасную команду оболочки.", + "allowed_commands": [ + "git add .", + "git commit -m \"...\"", + "git status", + "pytest ...", + "./gradlew test" + ], + "prerequisites": "Git должен быть настроен (user.name, user.email) для выполнения коммитов." + } + ] + }, + "PRIMARY_DIRECTIVE": "Твоя задача — обработать **весь пакет** `Work Order`'ов из очереди `tasks/pending_qa/`. Для каждого из них ты проводишь трехфазный аудит. Если **все** задачи в пакете проходят аудит, ты делаешь **единый коммит** с изменениями в репозиторий. Если хотя бы одна задача проваливается, ты возвращаешь все проваленные задачи на доработку.", "MASTER_WORKFLOW": { - "name": "Three_Phase_Audit_Cycle", + "name": "Batch_Audit_And_Commit_Cycle", + "DESCRIPTION": "Этот воркфлоу оперирует пакетом всех задач, найденных в `tasks/pending_qa/`. Финальный коммит выполняется только если ВСЕ задачи в пакете проходят аудит.", + "VARIABLES": { + "task_batch": [], + "assurance_reports": [], + "all_passed": true + }, "STEP": [ { "id": "1", - "name": "Context_Loading", + "name": "Batch_Loading", "ACTION": [ - "1. Найти и прочитать первый `Work Order` из директории `tasks/pending_qa/`.", - "2. Загрузить глобальный контекст `tech_spec/PROJECT_MANIFEST.xml`.", - "3. Прочитать актуальное содержимое кода из файла, указанного в ``." + "1. Найти **все** `Work Order` файлы в директории `tasks/pending_qa/` и загрузить их в `task_batch`.", + "2. Если `task_batch` пуст, завершить работу с логом 'Нет задач для QA'." ] }, { "id": "2", - "name": "Phase 1: Static Semantic Audit", - "DESCRIPTION": "Проверка на соответствие семантическим правилам без запуска кода.", + "name": "Iterative_Audit", "ACTION": [ - "1. Проверить код на полное соответствие `SEMANTIC_ENRICHMENT_PROTOCOL`.", - "2. Убедиться, что все сущности (`[ENTITY]`) и связи (`[RELATION]`) корректно размечены и соответствуют логике кода.", - "3. Проверить соблюдение таксономии в якоре `[SEMANTICS]`.", - "4. Проверить наличие и корректность KDoc-контрактов для всех публичных сущностей.", - "5. Собрать все найденные нарушения в секцию `semantic_audit_findings`." + "**FOR EACH** `work_order` in `task_batch`:", + " a. Вызвать `SINGLE_TASK_AUDIT_SUBROUTINE` с `work_order` в качестве входа.", + " b. Сохранить сгенерированный `Assurance Report` в `assurance_reports`." ] }, { "id": "3", - "name": "Phase 2: Unit Test Generation & Execution", - "DESCRIPTION": "Динамическая проверка функциональной корректности на основе контрактов и критериев приемки.", + "name": "Aggregate_Results_And_Finalize_Batch", "ACTION": [ - "1. **Сгенерировать тесты на основе контрактов:** Для каждой публичной функции прочитать ее KDoc (`@param`, `@return`, `@throws`) и сгенерировать unit-тесты (например, с использованием Kotest), которые проверяют эти контракты:", - " - Тесты для 'happy path', проверяющие постусловия (`@return`).", - " - Тесты, передающие невалидные данные, которые должны вызывать исключения, описанные в `@throws`.", - " - Тесты для пограничных случаев (null, empty, zero).", - "2. **Сгенерировать тесты на основе критериев приемки:** Прочитать каждый тег `` из `` в `Work Order` и сгенерировать соответствующий ему бизнес-ориентированный тест.", - "3. Сохранить сгенерированные тесты во временный тестовый файл.", - "4. **Выполнить все сгенерированные тесты** и собрать результаты (успех/провал, сообщения об ошибках).", - "5. Собрать все проваленные тесты в секцию `unit_test_findings`." - ] - }, - { - "id": "4", - "name": "Phase 3: Integration & Regression Analysis", - "DESCRIPTION": "Проверка влияния изменений на остальную часть системы.", - "ACTION": [ - "1. Проанализировать `[RELATION]` якоря в измененном коде, чтобы определить, какие другие сущности от него зависят (кто его `CALLS`, `CONSUMES_STATE`, etc.).", - "2. Используя `PROJECT_MANIFEST.xml`, найти существующие тесты для этих зависимых сущностей.", - "3. Запустить эти регрессионные тесты.", - "4. Собрать все проваленные регрессионные тесты в секцию `regression_findings`." - ] - }, - { - "id": "5", - "name": "Generate_Assurance_Report_And_Finalize", - "ACTION": [ - "1. Собрать результаты всех трех фаз в единый `Assurance Report` согласно схеме `ASSURANCE_REPORT_SCHEMA`.", - "2. **Если `overall_status` в отчете == 'PASSED':**", - " a. Изменить статус в файле `Work Order` на `status=\"completed\"`.", - " b. Переместить файл `Work Order` в `tasks/completed/`.", - " c. Залогировать успешное прохождение QA.", - "3. **Если `overall_status` в отчете == 'FAILED':**", - " a. Изменить статус в файле `Work Order` на `status=\"pending\"`.", - " b. Добавить в XML `Work Order` новую секцию `` с полным содержимым `Assurance Report`.", - " c. Переместить файл `Work Order` обратно в `tasks/pending/` для исправления Агентом-Разработчиком.", - " d. Залогировать провал QA с указанием количества дефектов." + "1. Проверить `overall_status` каждого отчета в `assurance_reports`. Если хотя бы один из них 'FAILED', установить `all_passed` в `false`.", + "2. **IF `all_passed` is `true`:**", + " Передать управление в `SUCCESS_WORKFLOW`.", + "3. **ELSE:**", + " Передать управление в `FAILURE_WORKFLOW`." ] } ] }, - "ASSURANCE_REPORT_SCHEMA": { - "name": "The_Assurance_Report_File", - "DESCRIPTION": "Строгий формат для отчета о качестве. Является моим главным артефактом.", - "STRUCTURE": "\n\n \n intent-unique-id\n path/to/file.kt\n {ISO_DATETIME}\n PASSED | FAILED\n \n \n \n \n com.example.MyClass:42\n Отсутствует обязательный замыкающий якорь [END_ENTITY] для класса 'MyClass'.\n SemanticLintingCompliance.EntityContainerization\n \n \n \n\n \n \n GeneratedTest: 'validatePassword'\n Тест на основе Acceptance Criterion 'AC-1' провален. Ожидалась ошибка 'TooShort' для пароля '123', но результат был 'Valid'.\n WorkOrder.ACCEPTANCE_CRITERIA[AC-1]\n \n \n \n \n \n \n ExistingTest: 'LoginViewModelTest'\n Регрессионный тест 'testSuccessfulLogin' провален. Вероятно, изменения в 'validatePassword' повлияли на логику ViewModel.\n LoginViewModel\n \n \n \n" + "SUB_WORKFLOWS": { + "SINGLE_TASK_AUDIT_SUBROUTINE": { + "DESCRIPTION": "Выполняет полный аудит для одной задачи и возвращает `Assurance Report`.", + "INPUT": "work_order", + "STEPS": [ + "Phase 1: Static Semantic Audit (Проверка семантики)", + "Phase 2: Unit Test Generation & Execution (Генерация и запуск unit-тестов)", + "Phase 3: Integration & Regression Analysis (Регрессионный анализ)", + "Return: Сгенерированный `Assurance Report`" + ] + }, + "SUCCESS_WORKFLOW": { + "DESCRIPTION": "Выполняется, если все задачи в пакете прошли проверку.", + "STEPS": [ + { + "id": "S1", + "name": "Archive_Tasks", + "ACTION": "Для каждого `work_order` в `task_batch`:\n a. Изменить статус на `completed`.\n b. Переместить файл в `tasks/completed/`." + }, + { + "id": "S2", + "name": "Stage_Changes", + "ACTION": "Выполнить `ExecuteShellCommand git add .` для добавления всех изменений (код, тесты, перемещенные задачи) в индекс." + }, + { + "id": "S3", + "name": "Formulate_Commit_Message", + "ACTION": "Сгенерировать сообщение для коммита согласно `COMMIT_MESSAGE_SCHEMA`. Если в пакете несколько задач, сообщение должно их перечислять." + }, + { + "id": "S4", + "name": "Execute_Commit", + "ACTION": "Выполнить `ExecuteShellCommand git commit -m \"{сгенерированное_сообщение}\"`." + }, + { + "id": "S5", + "name": "Log_Success", + "ACTION": "Залогировать успешное прохождение QA и коммит для всего пакета." + } + ] + }, + "FAILURE_WORKFLOW": { + "DESCRIPTION": "Выполняется, если хотя бы одна задача в пакете провалила проверку.", + "STEPS": [ + { + "id": "F1", + "name": "Return_Failed_Tasks", + "ACTION": "Для каждого `work_order` и соответствующего `report`:\n a. **IF `report.overall_status` is `FAILED`:**\n i. Изменить статус `work_order` на `pending`.\n ii. Добавить в него секцию `` с содержимым отчета.\n iii. Переместить файл обратно в `tasks/pending/`." + }, + { + "id": "F2", + "name": "Handle_Passed_Tasks_In_Failed_Batch", + "ACTION": "Для каждого `work_order`, который прошел проверку, оставить его в `tasks/pending_qa/` для следующего цикла, чтобы он был включен в следующий успешный коммит." + }, + { + "id": "F3", + "name": "Log_Failure", + "ACTION": "Залогировать провал QA для всего пакета, перечислив ID проваленных задач." + } + ] + } }, - "UPDATED_WORK_ORDER_SCHEMA": { - "name": "Work_Order_With_Defect_Report", - "DESCRIPTION": "Пример того, как `Work Order` возвращается Агенту-Разработчику в случае провала QA.", - "STRUCTURE": "\n FIX_DEFECTS\n path/to/file.kt\n \n \n \n \n \n \n \n \n" + "COMMIT_MESSAGE_SCHEMA": { + "name": "Structured_Commit_Message", + "DESCRIPTION": "Строгий формат для сообщений коммита, обеспечивающий трассируемость.", + "TEMPLATE": "feat(agent): {summary}\n\nАвтоматизированная реализация на основе `Work Order`.\n\nЗавершенные задачи:\n- {work_order_id_1}: {work_order_summary_1}\n- {work_order_id_2}: {work_order_summary_2}", + "EXAMPLE": "feat(agent): Implement password validation\n\nАвтоматизированная реализация на основе `Work Order`.\n\nЗавершенные задачи:\n- intent-12345: Реализовать функцию валидации пароля" } } } \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2b263bc..38cd617 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -88,6 +88,9 @@ dependencies { // [DEPENDENCY] Testing testImplementation(Libs.junit) + testImplementation(Libs.kotestRunnerJunit5) + testImplementation(Libs.kotestAssertionsCore) + testImplementation(Libs.mockk) androidTestImplementation(Libs.extJunit) androidTestImplementation(Libs.espressoCore) androidTestImplementation(platform(Libs.composeBom)) diff --git a/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt b/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt index 2ae975e..87444cc 100644 --- a/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt +++ b/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt @@ -9,10 +9,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import com.homebox.lens.ui.screen.dashboard.DashboardScreen import com.homebox.lens.ui.screen.inventorylist.InventoryListScreen import com.homebox.lens.ui.screen.itemdetails.ItemDetailsScreen @@ -74,10 +76,16 @@ fun NavGraph( navigationActions = navigationActions ) } - composable(route = Screen.ItemEdit.route) { + composable( + route = Screen.ItemEdit.route, + arguments = listOf(navArgument("itemId") { nullable = true }) + ) { backStackEntry -> + val itemId = backStackEntry.arguments?.getString("itemId") ItemEditScreen( currentRoute = currentRoute, - navigationActions = navigationActions + navigationActions = navigationActions, + itemId = itemId, + onSaveSuccess = { navController.popBackStack() } ) } composable(Screen.LabelsList.route) { diff --git a/app/src/main/java/com/homebox/lens/navigation/Screen.kt b/app/src/main/java/com/homebox/lens/navigation/Screen.kt index 0100b33..9219c6a 100644 --- a/app/src/main/java/com/homebox/lens/navigation/Screen.kt +++ b/app/src/main/java/com/homebox/lens/navigation/Screen.kt @@ -59,19 +59,15 @@ sealed class Screen(val route: String) { // [END_ENTITY: Object('ItemDetails')] // [ENTITY: Object('ItemEdit')] - data object ItemEdit : Screen("item_edit_screen/{itemId}") { + data object ItemEdit : Screen("item_edit_screen?itemId={itemId}") { // [ENTITY: Function('createRoute')] /** * @summary Создает маршрут для экрана редактирования элемента с указанным ID. - * @param itemId ID элемента для редактирования. + * @param itemId ID элемента для редактирования. Null, если создается новый элемент. * @return Строку полного маршрута. - * @throws IllegalArgumentException если itemId пустой. */ - fun createRoute(itemId: String): String { - require(itemId.isNotBlank()) { "itemId не может быть пустым." } - val route = "item_edit_screen/$itemId" - check(route.endsWith(itemId)) { "Маршрут должен заканчиваться на itemId." } - return route + fun createRoute(itemId: String? = null): String { + return itemId?.let { "item_edit_screen?itemId=$it" } ?: "item_edit_screen" } // [END_ENTITY: Function('createRoute')] } diff --git a/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt index 548928f..9b679fc 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt @@ -5,35 +5,135 @@ package com.homebox.lens.ui.screen.itemedit // [IMPORTS] +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import com.homebox.lens.R import com.homebox.lens.navigation.NavigationActions import com.homebox.lens.ui.common.MainScaffold +import timber.log.Timber // [END_IMPORTS] // [ENTITY: Function('ItemEditScreen')] // [RELATION: Function('ItemEditScreen')] -> [DEPENDS_ON] -> [Class('NavigationActions')] +// [RELATION: Function('ItemEditScreen')] -> [DEPENDS_ON] -> [ViewModel('ItemEditViewModel')] +// [RELATION: Function('ItemEditScreen')] -> [CONSUMES_STATE] -> [DataClass('ItemEditUiState')] // [RELATION: Function('ItemEditScreen')] -> [CALLS] -> [Function('MainScaffold')] /** * @summary Composable-функция для экрана "Редактирование элемента". * @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer. * @param navigationActions Объект с навигационными действиями. + * @param itemId ID элемента для редактирования. Null, если создается новый элемент. + * @param viewModel ViewModel для управления состоянием экрана. + * @param onSaveSuccess Callback, вызываемый после успешного сохранения товара. */ @Composable fun ItemEditScreen( currentRoute: String?, - navigationActions: NavigationActions + navigationActions: NavigationActions, + itemId: String?, + viewModel: ItemEditViewModel = viewModel(), + onSaveSuccess: () -> Unit ) { + val uiState by viewModel.uiState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(itemId) { + Timber.i("[INFO][ENTRYPOINT][item_edit_screen_init] Initializing ItemEditScreen for item ID: %s", itemId) + viewModel.loadItem(itemId) + } + + LaunchedEffect(uiState.error) { + uiState.error?.let { + snackbarHostState.showSnackbar(it) + Timber.e("[ERROR][UI_ERROR][item_edit_error] Displaying error: %s", it) + } + } + + LaunchedEffect(Unit) { + viewModel.saveCompleted.collect { + Timber.i("[INFO][ACTION][save_completed_callback] Item save completed. Triggering onSaveSuccess.") + onSaveSuccess() + } + } + MainScaffold( topBarTitle = stringResource(id = R.string.item_edit_title), currentRoute = currentRoute, navigationActions = navigationActions ) { - // [AI_NOTE]: Implement Item Edit Screen UI - Text(text = "Item Edit Screen") + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + floatingActionButton = { + FloatingActionButton(onClick = { + Timber.i("[INFO][ACTION][save_button_click] Save button clicked.") + viewModel.saveItem() + }) { + Icon(Icons.Filled.Save, contentDescription = stringResource(R.string.save_item)) + } + } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(it) + .padding(16.dp) + ) { + if (uiState.isLoading) { + CircularProgressIndicator(modifier = Modifier.fillMaxWidth()) + } else { + uiState.item?.let { item -> + OutlinedTextField( + value = item.name, + onValueChange = { viewModel.updateName(it) }, + label = { Text(stringResource(R.string.item_name)) }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = item.description ?: "", + onValueChange = { viewModel.updateDescription(it) }, + label = { Text(stringResource(R.string.item_description)) }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = item.quantity.toString(), + onValueChange = { viewModel.updateQuantity(it.toIntOrNull() ?: 0) }, + label = { Text(stringResource(R.string.item_quantity)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + // Add more fields as needed + } + } + } + } } } // [END_ENTITY: Function('ItemEditScreen')] -// [END_FILE_ItemEditScreen.kt] \ No newline at end of file +// [END_FILE_ItemEditScreen.kt] diff --git a/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditViewModel.kt b/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditViewModel.kt index 790830a..4ca8c27 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditViewModel.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditViewModel.kt @@ -1,21 +1,214 @@ // [PACKAGE] com.homebox.lens.ui.screen.itemedit // [FILE] ItemEditViewModel.kt // [SEMANTICS] ui, viewmodel, item_edit + package com.homebox.lens.ui.screen.itemedit // [IMPORTS] import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.homebox.lens.domain.model.Item +import com.homebox.lens.domain.model.ItemCreate +import com.homebox.lens.domain.model.Label +import com.homebox.lens.domain.model.Location +import com.homebox.lens.domain.usecase.CreateItemUseCase +import com.homebox.lens.domain.usecase.GetItemDetailsUseCase +import com.homebox.lens.domain.usecase.UpdateItemUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject // [END_IMPORTS] +// [ENTITY: DataClass('ItemEditUiState')] +/** + * @summary UI state for the item edit screen. + * @param item The item being edited, or null if creating a new item. + * @param isLoading Whether data is currently being loaded or saved. + * @param error An error message if an operation failed. + */ +data class ItemEditUiState( + val item: Item? = null, + val isLoading: Boolean = false, + val error: String? = null +) +// [END_ENTITY: DataClass('ItemEditUiState')] + // [ENTITY: ViewModel('ItemEditViewModel')] +// [RELATION: ViewModel('ItemEditViewModel')] -> [DEPENDS_ON] -> [UseCase('CreateItemUseCase')] +// [RELATION: ViewModel('ItemEditViewModel')] -> [DEPENDS_ON] -> [UseCase('UpdateItemUseCase')] +// [RELATION: ViewModel('ItemEditViewModel')] -> [DEPENDS_ON] -> [UseCase('GetItemDetailsUseCase')] +// [RELATION: ViewModel('ItemEditViewModel')] -> [EMITS_STATE] -> [DataClass('ItemEditUiState')] /** * @summary ViewModel for the item edit screen. */ @HiltViewModel -class ItemEditViewModel @Inject constructor() : ViewModel() { - // [AI_NOTE]: Implement UI state +class ItemEditViewModel @Inject constructor( + private val createItemUseCase: CreateItemUseCase, + private val updateItemUseCase: UpdateItemUseCase, + private val getItemDetailsUseCase: GetItemDetailsUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(ItemEditUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _saveCompleted = MutableSharedFlow() + val saveCompleted: SharedFlow = _saveCompleted.asSharedFlow() + + // [ENTITY: Function('loadItem')] + /** + * @summary Loads item details for editing or prepares for new item creation. + * @param itemId The ID of the item to load. If null, a new item is being created. + * @sideeffect Updates `_uiState` with loading, success, or error states. + */ + fun loadItem(itemId: String?) { + Timber.i("[INFO][ENTRYPOINT][loading_item] Attempting to load item with ID: %s", itemId) + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + if (itemId == null) { + Timber.i("[INFO][ACTION][new_item_preparation] Preparing for new item creation.") + _uiState.value = _uiState.value.copy(isLoading = false, item = Item(id = "", name = "", description = null, quantity = 0, image = null, location = null, labels = emptyList(), value = null, createdAt = null)) + } else { + try { + Timber.i("[INFO][ACTION][fetching_item_details] Fetching details for item ID: %s", itemId) + val itemOut = getItemDetailsUseCase(itemId) + Timber.d("[DEBUG][ACTION][mapping_item_out_to_item] Mapping ItemOut to Item for UI state.") + val item = Item( + id = itemOut.id, + name = itemOut.name, + description = itemOut.description, + quantity = itemOut.quantity, + image = itemOut.images.firstOrNull()?.path, // Assuming first image is the main one + location = itemOut.location?.let { Location(it.id, it.name) }, // Simplified mapping + labels = itemOut.labels.map { Label(it.id, it.name) }, // Simplified mapping + value = itemOut.value?.toBigDecimal(), + createdAt = itemOut.createdAt + ) + _uiState.value = _uiState.value.copy(isLoading = false, item = item) + Timber.i("[INFO][ACTION][item_details_fetched] Successfully fetched item details for ID: %s", itemId) + } catch (e: Exception) { + Timber.e(e, "[ERROR][FALLBACK][item_load_failed] Failed to load item details for ID: %s", itemId) + _uiState.value = _uiState.value.copy(isLoading = false, error = e.localizedMessage) + } + } + } + } + // [END_ENTITY: Function('loadItem')] + + // [ENTITY: Function('saveItem')] + /** + * @summary Saves the current item, either creating a new one or updating an existing one. + * @sideeffect Updates `_uiState` with loading, success, or error states. Calls `createItemUseCase` or `updateItemUseCase`. + * @throws IllegalStateException if `uiState.value.item` is null when attempting to save. + */ + fun saveItem() { + Timber.i("[INFO][ENTRYPOINT][saving_item] Attempting to save item.") + viewModelScope.launch { + val currentItem = _uiState.value.item + require(currentItem != null) { "[CONTRACT_VIOLATION][PRECONDITION][item_not_present] Cannot save a null item." } + + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + try { + if (currentItem.id.isBlank()) { + Timber.i("[INFO][ACTION][creating_new_item] Creating new item: %s", currentItem.name) + val createdItemSummary = createItemUseCase(ItemCreate( + name = currentItem.name, + description = currentItem.description, + quantity = currentItem.quantity, + assetId = null, + notes = null, + serialNumber = null, + value = null, + purchasePrice = null, + purchaseDate = null, + warrantyUntil = null, + locationId = currentItem.location?.id, + parentId = null, + labelIds = currentItem.labels.map { it.id } + )) + Timber.d("[DEBUG][ACTION][mapping_item_summary_to_item] Mapping ItemSummary to Item for UI state.") + val createdItem = Item( + id = createdItemSummary.id, + name = createdItemSummary.name, + description = null, // ItemSummary does not have description + quantity = 0, // ItemSummary does not have quantity + image = null, // ItemSummary does not have image + location = null, // ItemSummary does not have location + labels = emptyList(), // ItemSummary does not have labels + value = null, // ItemSummary does not have value + createdAt = null // ItemSummary does not have createdAt + ) + _uiState.value = _uiState.value.copy(isLoading = false, item = createdItem) + Timber.i("[INFO][ACTION][new_item_created] Successfully created new item with ID: %s", createdItem.id) + _saveCompleted.emit(Unit) + } else { + Timber.i("[INFO][ACTION][updating_existing_item] Updating existing item with ID: %s", currentItem.id) + val updatedItemOut = updateItemUseCase(currentItem) + Timber.d("[DEBUG][ACTION][mapping_item_out_to_item] Mapping ItemOut to Item for UI state.") + val updatedItem = Item( + id = updatedItemOut.id, + name = updatedItemOut.name, + description = updatedItemOut.description, + quantity = updatedItemOut.quantity, + image = updatedItemOut.images.firstOrNull()?.path, + location = updatedItemOut.location?.let { Location(it.id, it.name) }, + labels = updatedItemOut.labels.map { Label(it.id, it.name) }, + value = updatedItemOut.value.toBigDecimal(), + createdAt = updatedItemOut.createdAt + ) + _uiState.value = _uiState.value.copy(isLoading = false, item = updatedItem) + Timber.i("[INFO][ACTION][item_updated] Successfully updated item with ID: %s", updatedItem.id) + _saveCompleted.emit(Unit) + } + } catch (e: Exception) { + Timber.e(e, "[ERROR][FALLBACK][item_save_failed] Failed to save item.") + _uiState.value = _uiState.value.copy(isLoading = false, error = e.localizedMessage) + } + } + } + // [END_ENTITY: Function('saveItem')] + + // [ENTITY: Function('updateName')] + /** + * @summary Updates the name of the item in the UI state. + * @param newName The new name for the item. + * @sideeffect Updates the `item` in `_uiState`. + */ + fun updateName(newName: String) { + Timber.d("[DEBUG][ACTION][updating_item_name] Updating item name to: %s", newName) + _uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(name = newName)) + } + // [END_ENTITY: Function('updateName')] + + // [ENTITY: Function('updateDescription')] + /** + * @summary Updates the description of the item in the UI state. + * @param newDescription The new description for the item. + * @sideeffect Updates the `item` in `_uiState`. + */ + fun updateDescription(newDescription: String) { + Timber.d("[DEBUG][ACTION][updating_item_description] Updating item description to: %s", newDescription) + _uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(description = newDescription)) + } + // [END_ENTITY: Function('updateDescription')] + + // [ENTITY: Function('updateQuantity')] + /** + * @summary Updates the quantity of the item in the UI state. + * @param newQuantity The new quantity for the item. + * @sideeffect Updates the `item` in `_uiState`. + */ + fun updateQuantity(newQuantity: Int) { + Timber.d("[DEBUG][ACTION][updating_item_quantity] Updating item quantity to: %d", newQuantity) + _uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(quantity = newQuantity)) + } + // [END_ENTITY: Function('updateQuantity')] } // [END_ENTITY: ViewModel('ItemEditViewModel')] -// [END_FILE_ItemEditViewModel.kt] \ No newline at end of file +// [END_FILE_ItemEditViewModel.kt] diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6a2dd84..dba5cc8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -44,6 +44,11 @@ Места хранения Поиск + Сохранить + Название + Описание + Количество + Создать локацию Редактировать локацию diff --git a/app/src/test/java/com/homebox/lens/ui/screen/itemedit/ItemEditViewModelTest.kt b/app/src/test/java/com/homebox/lens/ui/screen/itemedit/ItemEditViewModelTest.kt new file mode 100644 index 0000000..139bf7b --- /dev/null +++ b/app/src/test/java/com/homebox/lens/ui/screen/itemedit/ItemEditViewModelTest.kt @@ -0,0 +1,316 @@ +// [PACKAGE] com.homebox.lens.ui.screen.itemedit +// [FILE] ItemEditViewModelTest.kt +// [SEMANTICS] testing, viewmodel, unit_test + +package com.homebox.lens.ui.screen.itemedit + +// [IMPORTS] +import androidx.lifecycle.viewModelScope +import com.homebox.lens.domain.model.Item +import com.homebox.lens.domain.model.ItemCreate +import com.homebox.lens.domain.model.ItemOut +import com.homebox.lens.domain.model.ItemSummary +import com.homebox.lens.domain.model.Label +import com.homebox.lens.domain.model.Location +import com.homebox.lens.domain.model.LocationOut +import com.homebox.lens.domain.model.LabelOut +import com.homebox.lens.domain.usecase.CreateItemUseCase +import com.homebox.lens.domain.usecase.GetItemDetailsUseCase +import com.homebox.lens.domain.usecase.UpdateItemUseCase +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.assertions.throwables.shouldThrow +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import java.math.BigDecimal +// [END_IMPORTS] + +// [ENTITY: Class('ItemEditViewModelTest')] +// [RELATION: Class('ItemEditViewModelTest')] -> [TESTS] -> [ViewModel('ItemEditViewModel')] +/** + * @summary Unit tests for [ItemEditViewModel]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ItemEditViewModelTest : FunSpec({ + + val createItemUseCase = mockk() + val updateItemUseCase = mockk() + val getItemDetailsUseCase = mockk() + + lateinit var viewModel: ItemEditViewModel + + val testDispatcher = UnconfinedTestDispatcher() + + beforeEach { + Dispatchers.setMain(testDispatcher) + viewModel = ItemEditViewModel(createItemUseCase, updateItemUseCase, getItemDetailsUseCase) + } + + afterEach { + Dispatchers.resetMain() + } + + // [ENTITY: Function('loadItem - new item creation')] + /** + * @summary Tests that loadItem with null itemId prepares for new item creation. + */ + test("loadItem with null itemId should prepare for new item creation") { + viewModel.loadItem(null) + + val uiState = viewModel.uiState.first() + uiState.isLoading shouldBe false + uiState.error shouldBe null + uiState.item shouldBe Item(id = "", name = "", description = null, quantity = 0, image = null, location = null, labels = emptyList(), value = null, createdAt = null) + } + // [END_ENTITY: Function('loadItem - new item creation')] + + // [ENTITY: Function('loadItem - existing item loading success')] + /** + * @summary Tests that loadItem with an itemId successfully loads an existing item. + */ + test("loadItem with itemId should load existing item successfully") { + val itemId = "test_item_id" + val itemOut = ItemOut( + id = itemId, + name = "Loaded Item", + assetId = null, + description = "Description", + notes = null, + serialNumber = null, + quantity = 5, + isArchived = false, + value = 100.0, + purchasePrice = null, + purchaseDate = null, + warrantyUntil = null, + location = LocationOut("loc1", "Location 1", "#FFFFFF", false, "2025-01-01T00:00:00Z", "2025-01-01T00:00:00Z"), + parent = null, + children = emptyList(), + labels = listOf(LabelOut("lab1", "Label 1", "#FFFFFF", false, "2025-01-01T00:00:00Z", "2025-01-01T00:00:00Z")), + attachments = emptyList(), + images = emptyList(), + fields = emptyList(), + maintenance = emptyList(), + createdAt = "2025-01-01T00:00:00Z", + updatedAt = "2025-01-01T00:00:00Z" + ) + + coEvery { getItemDetailsUseCase(itemId) } returns itemOut + + viewModel.loadItem(itemId) + + val uiState = viewModel.uiState.first() + uiState.isLoading shouldBe false + uiState.error shouldBe null + uiState.item?.id shouldBe itemOut.id + uiState.item?.name shouldBe itemOut.name + } + // [END_ENTITY: Function('loadItem - existing item loading success')] + + // [ENTITY: Function('loadItem - existing item loading failure')] + /** + * @summary Tests that loadItem with an itemId handles loading failure. + */ + test("loadItem with itemId should handle loading failure") { + val itemId = "test_item_id" + val errorMessage = "Failed to fetch item" + coEvery { getItemDetailsUseCase(itemId) } throws Exception(errorMessage) + + viewModel.loadItem(itemId) + + val uiState = viewModel.uiState.first() + uiState.isLoading shouldBe false + uiState.error shouldBe errorMessage + uiState.item shouldBe null + } + // [END_ENTITY: Function('loadItem - existing item loading failure')] + + // [ENTITY: Function('saveItem - new item creation success')] + /** + * @summary Tests that saveItem successfully creates a new item. + */ + test("saveItem should create new item successfully") { + val newItem = Item( + id = "", // New item has blank ID + name = "New Item", + description = null, + quantity = 1, + image = null, + location = null, + labels = emptyList(), + value = null, + createdAt = null + ) + val createdSummary = ItemSummary("new_id", "New Item") + + viewModel.uiState.value = ItemEditUiState(item = newItem) + coEvery { createItemUseCase(any()) } returns createdSummary + + viewModel.saveItem() + + val uiState = viewModel.uiState.first() + uiState.isLoading shouldBe false + uiState.error shouldBe null + uiState.item?.id shouldBe createdSummary.id + uiState.item?.name shouldBe createdSummary.name + coVerify(exactly = 1) { createItemUseCase(ItemCreate( + name = newItem.name, + description = newItem.description, + quantity = newItem.quantity, + assetId = null, + notes = null, + serialNumber = null, + value = null, + purchasePrice = null, + purchaseDate = null, + warrantyUntil = null, + locationId = newItem.location?.id, + parentId = null, + labelIds = newItem.labels.map { it.id } + )) } + viewModel.saveCompleted.first() shouldBe Unit + } + // [END_ENTITY: Function('saveItem - new item creation success')] + + // [ENTITY: Function('saveItem - new item creation failure')] + /** + * @summary Tests that saveItem handles new item creation failure. + */ + test("saveItem should handle new item creation failure") { + val newItem = Item( + id = "", + name = "New Item", + description = null, + quantity = 1, + image = null, + location = null, + labels = emptyList(), + value = null, + createdAt = null + ) + val errorMessage = "Failed to create item" + + viewModel.uiState.value = ItemEditUiState(item = newItem) + coEvery { createItemUseCase(any()) } throws Exception(errorMessage) + + viewModel.saveItem() + + val uiState = viewModel.uiState.first() + uiState.isLoading shouldBe false + uiState.error shouldBe errorMessage + coVerify(exactly = 1) { createItemUseCase(any()) } + } + // [END_ENTITY: Function('saveItem - new item creation failure')] + + // [ENTITY: Function('saveItem - existing item update success')] + /** + * @summary Tests that saveItem successfully updates an existing item. + */ + test("saveItem should update existing item successfully") { + val existingItem = Item( + id = "existing_id", + name = "Existing Item", + description = null, + quantity = 1, + image = null, + location = null, + labels = emptyList(), + value = null, + createdAt = null + ) + val updatedItemOut = ItemOut( + id = "existing_id", + name = "Updated Item", + assetId = null, + description = null, + notes = null, + serialNumber = null, + quantity = 2, + isArchived = false, + value = 200.0, + purchasePrice = null, + purchaseDate = null, + warrantyUntil = null, + location = null, + parent = null, + children = emptyList(), + labels = emptyList(), + attachments = emptyList(), + images = emptyList(), + fields = emptyList(), + maintenance = emptyList(), + createdAt = "2025-01-01T00:00:00Z", + updatedAt = "2025-01-01T00:00:00Z" + ) + + viewModel.uiState.value = ItemEditUiState(item = existingItem) + coEvery { updateItemUseCase(any()) } returns updatedItemOut + + viewModel.saveItem() + + val uiState = viewModel.uiState.first() + uiState.isLoading shouldBe false + uiState.error shouldBe null + uiState.item?.id shouldBe updatedItemOut.id + uiState.item?.name shouldBe updatedItemOut.name + coVerify(exactly = 1) { updateItemUseCase(existingItem) } + viewModel.saveCompleted.first() shouldBe Unit + } + // [END_ENTITY: Function('saveItem - existing item update success')] + + // [ENTITY: Function('saveItem - existing item update failure')] + /** + * @summary Tests that saveItem handles existing item update failure. + */ + test("saveItem should handle existing item update failure") { + val existingItem = Item( + id = "existing_id", + name = "Existing Item", + description = null, + quantity = 1, + image = null, + location = null, + labels = emptyList(), + value = null, + createdAt = null + ) + val errorMessage = "Failed to update item" + + viewModel.uiState.value = ItemEditUiState(item = existingItem) + coEvery { updateItemUseCase(any()) } throws Exception(errorMessage) + + viewModel.saveItem() + + val uiState = viewModel.uiState.first() + uiState.isLoading shouldBe false + uiState.error shouldBe errorMessage + coVerify(exactly = 1) { updateItemUseCase(any()) } + } + // [END_ENTITY: Function('saveItem - existing item update failure')] + + // [ENTITY: Function('saveItem - null item')] + /** + * @summary Tests that saveItem throws IllegalStateException when item in uiState is null. + */ + test("saveItem should throw IllegalStateException when item in uiState is null") { + viewModel.uiState.value = ItemEditUiState(item = null) + + val exception = shouldThrow { + viewModel.saveItem() + } + exception.message shouldBe "[CONTRACT_VIOLATION][PRECONDITION][item_not_present] Cannot save a null item." + } + // [END_ENTITY: Function('saveItem - null item')] +}) +// [END_ENTITY: Class('ItemEditViewModelTest')] +// [END_FILE_ItemEditViewModelTest.kt] \ No newline at end of file diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index f70531d..2acc404 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -45,6 +45,10 @@ object Versions { const val junit = "4.13.2" const val extJunit = "1.1.5" const val espresso = "3.5.1" + + // Testing + const val kotest = "5.8.0" + const val mockk = "1.13.10" } // [END_ENTITY: Object('Versions')] @@ -98,6 +102,9 @@ object Libs { const val composeUiTooling = "androidx.compose.ui:ui-tooling" const val composeUiTestManifest = "androidx.compose.ui:ui-test-manifest" + const val kotestRunnerJunit5 = "io.kotest:kotest-runner-junit5:${Versions.kotest}" + const val kotestAssertionsCore = "io.kotest:kotest-assertions-core:${Versions.kotest}" + const val mockk = "io.mockk:mockk:${Versions.mockk}" } // [END_ENTITY: Object('Libs')] diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index b05e5be..672f0e0 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -20,6 +20,12 @@ dependencies { // [DEPENDENCY] Javax Inject for DI annotations implementation("javax.inject:javax.inject:1") + + // [DEPENDENCY] Testing + testImplementation(Libs.junit) + testImplementation(Libs.kotestRunnerJunit5) + testImplementation(Libs.kotestAssertionsCore) + testImplementation(Libs.mockk) } // [END_FILE_domain/build.gradle.kts] diff --git a/domain/src/main/java/com/homebox/lens/domain/model/Item.kt b/domain/src/main/java/com/homebox/lens/domain/model/Item.kt index 1a02d33..6dfec66 100644 --- a/domain/src/main/java/com/homebox/lens/domain/model/Item.kt +++ b/domain/src/main/java/com/homebox/lens/domain/model/Item.kt @@ -25,6 +25,7 @@ data class Item( val id: String, val name: String, val description: String?, + val quantity: Int, val image: String?, val location: Location?, val labels: List