From 3766a0debea7b1bef993b008ffd675c806891d97 Mon Sep 17 00:00:00 2001 From: Igor Kirillov Date: Wed, 29 Apr 2026 16:14:08 +0200 Subject: [PATCH 1/6] Don't overwrite `check_profile` from server with an empty string. ^ALT-10963 --- .../src/org/hyperskill/academy/learning/yaml/YamlMapper.kt | 3 +++ .../learning/yaml/format/student/StudentTaskChangeApplier.kt | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/hs-edu-format/src/org/hyperskill/academy/learning/yaml/YamlMapper.kt b/hs-edu-format/src/org/hyperskill/academy/learning/yaml/YamlMapper.kt index 6e0200c76..bb05565d9 100644 --- a/hs-edu-format/src/org/hyperskill/academy/learning/yaml/YamlMapper.kt +++ b/hs-edu-format/src/org/hyperskill/academy/learning/yaml/YamlMapper.kt @@ -21,6 +21,7 @@ import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillProject import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillStage import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillTopic import org.hyperskill.academy.learning.courseFormat.tasks.CodeTask +import org.hyperskill.academy.learning.courseFormat.tasks.RemoteEduTask import org.hyperskill.academy.learning.courseFormat.tasks.Task import org.hyperskill.academy.learning.courseFormat.tasks.TheoryTask import org.hyperskill.academy.learning.yaml.format.* @@ -31,6 +32,7 @@ import org.hyperskill.academy.learning.yaml.format.hyperskill.HyperskillStageMix import org.hyperskill.academy.learning.yaml.format.hyperskill.HyperskillTopicMixin import org.hyperskill.academy.learning.yaml.format.remote.DataTaskAttemptYamlMixin import org.hyperskill.academy.learning.yaml.format.remote.RemoteCourseYamlMixin +import org.hyperskill.academy.learning.yaml.format.remote.RemoteEduTaskYamlMixin import org.hyperskill.academy.learning.yaml.format.remote.RemoteStudyItemYamlMixin import org.hyperskill.academy.learning.yaml.format.student.FeedbackYamlMixin import org.hyperskill.academy.learning.yaml.format.student.StudentTaskFileYamlMixin @@ -87,6 +89,7 @@ object YamlMapper { addMixIn(Lesson::class.java, LessonYamlMixin::class.java) addMixIn(FrameworkLesson::class.java, FrameworkLessonYamlMixin::class.java) addMixIn(Task::class.java, StudentTaskYamlMixin::class.java) + addMixIn(RemoteEduTask::class.java, RemoteEduTaskYamlMixin::class.java) addMixIn(CodeTask::class.java, CodeTaskYamlMixin::class.java) addMixIn(TheoryTask::class.java, TheoryTaskYamlUtil::class.java) addMixIn(CheckFeedback::class.java, FeedbackYamlMixin::class.java) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt index c0baf37a4..bf084099e 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt @@ -27,7 +27,10 @@ class StudentTaskChangeApplier(project: Project) : TaskChangeApplier(project) { when (existingItem) { is EduTask -> { if (existingItem is RemoteEduTask) { - existingItem.checkProfile = (deserializedItem as RemoteEduTask).checkProfile + val newCheckProfile = (deserializedItem as RemoteEduTask).checkProfile + if (newCheckProfile.isNotEmpty()) { + existingItem.checkProfile = newCheckProfile + } } } } From 254e4a1e7da65daf7628d124abf0ad5595502c9a Mon Sep 17 00:00:00 2001 From: Oleksandr Liemiahov Date: Mon, 4 May 2026 15:20:32 +0300 Subject: [PATCH 2/6] add logging --- .../learning/yaml/format/student/StudentTaskChangeApplier.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt index bf084099e..52007343a 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt @@ -1,6 +1,7 @@ package org.hyperskill.academy.learning.yaml.format.student import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project import org.hyperskill.academy.learning.courseFormat.StudyItem import org.hyperskill.academy.learning.courseFormat.TaskFile @@ -11,9 +12,10 @@ import org.hyperskill.academy.learning.messages.EduCoreBundle import org.hyperskill.academy.learning.yaml.errorHandling.YamlLoadingException import org.hyperskill.academy.learning.yaml.format.TaskChangeApplier - class StudentTaskChangeApplier(project: Project) : TaskChangeApplier(project) { + private val LOG = logger() override fun applyChanges(existingItem: Task, deserializedItem: Task) { + LOG.info("Applying changes for task: ${existingItem} - ${deserializedItem}") if (existingItem.solutionHidden != deserializedItem.solutionHidden && !ApplicationManager.getApplication().isInternal) { throw YamlLoadingException(EduCoreBundle.message("yaml.editor.invalid.visibility.cannot.be.changed")) } From e44e407319e91f7518d11176d4a79c4867b58dac Mon Sep 17 00:00:00 2001 From: Oleksandr Liemiahov Date: Tue, 12 May 2026 09:44:57 +0300 Subject: [PATCH 3/6] fix opening project --- .../courseFormat/tasks/RemoteEduTask.kt | 6 ++++++ .../format/remote/RemoteEduTaskYamlMixin.kt | 7 +++++-- .../yaml/format/tasks/TaskYamlUtil.kt | 2 +- .../checker/HyperskillSubmitConnector.kt | 6 ++++++ .../academy/learning/yaml/YamlDeepLoader.kt | 18 ++++++++++++----- .../learning/yaml/YamlFormatSynchronizer.kt | 10 +++------- .../academy/learning/yaml/YamlLoader.kt | 6 +++++- .../student/StudentTaskChangeApplier.kt | 20 ++++++++++--------- .../hyperskill/academy/learning/yaml/utils.kt | 7 +++---- .../yaml/YamlSerializationTest.kt | 1 + .../yaml/YamlChangedAfterEventTest.kt | 2 +- 11 files changed, 55 insertions(+), 30 deletions(-) diff --git a/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/tasks/RemoteEduTask.kt b/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/tasks/RemoteEduTask.kt index 4194004ed..8eb049168 100644 --- a/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/tasks/RemoteEduTask.kt +++ b/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/tasks/RemoteEduTask.kt @@ -17,6 +17,12 @@ class RemoteEduTask : EduTask { * MUST NOT BE HARDCODED */ var checkProfile: String = "" + set(value) { + if (field != value) { + println("[DEBUG_LOG] RemoteEduTask.checkProfile set to '$value' (was '$field')") + } + field = value + } override val itemType: String = REMOTE_EDU_TASK_TYPE diff --git a/hs-edu-format/src/org/hyperskill/academy/learning/yaml/format/remote/RemoteEduTaskYamlMixin.kt b/hs-edu-format/src/org/hyperskill/academy/learning/yaml/format/remote/RemoteEduTaskYamlMixin.kt index 840b6c807..e9d83b661 100644 --- a/hs-edu-format/src/org/hyperskill/academy/learning/yaml/format/remote/RemoteEduTaskYamlMixin.kt +++ b/hs-edu-format/src/org/hyperskill/academy/learning/yaml/format/remote/RemoteEduTaskYamlMixin.kt @@ -1,5 +1,6 @@ package org.hyperskill.academy.learning.yaml.format.remote +import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonPropertyOrder import org.hyperskill.academy.learning.courseFormat.EduFormatNames.CHECK_PROFILE @@ -16,6 +17,8 @@ import org.hyperskill.academy.learning.yaml.format.student.StudentTaskYamlMixin @Suppress("unused") @JsonPropertyOrder(TYPE, CUSTOM_NAME, FILES, FEEDBACK_LINK, CHECK_PROFILE, STATUS, FEEDBACK, RECORD, TAGS) class RemoteEduTaskYamlMixin : StudentTaskYamlMixin() { - @JsonProperty(CHECK_PROFILE) - private lateinit var checkProfile: String + @get:JsonProperty(CHECK_PROFILE) + @set:JsonProperty(CHECK_PROFILE) + @get:JsonInclude(JsonInclude.Include.ALWAYS) + var checkProfile: String = "" } \ No newline at end of file diff --git a/hs-edu-format/src/org/hyperskill/academy/learning/yaml/format/tasks/TaskYamlUtil.kt b/hs-edu-format/src/org/hyperskill/academy/learning/yaml/format/tasks/TaskYamlUtil.kt index 60ec3359f..428b65636 100644 --- a/hs-edu-format/src/org/hyperskill/academy/learning/yaml/format/tasks/TaskYamlUtil.kt +++ b/hs-edu-format/src/org/hyperskill/academy/learning/yaml/format/tasks/TaskYamlUtil.kt @@ -81,7 +81,7 @@ abstract class TaskYamlMixin { protected open fun getAdditionalPropertiesForSerialization(): Map { // Filter out known field names that are already serialized by the mixin val knownFields = setOf("type", "custom_name", "files", "feedback_link", - "solution_hidden", "status", "feedback", "record", "tags") + "solution_hidden", "status", "feedback", "record", "tags", "check_profile") return additionalProperties.filterKeys { it !in knownFields } } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/checker/HyperskillSubmitConnector.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/checker/HyperskillSubmitConnector.kt index 643bccba6..6ca1884eb 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/checker/HyperskillSubmitConnector.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/checker/HyperskillSubmitConnector.kt @@ -79,6 +79,12 @@ object HyperskillSubmitConnector { } fun submitRemoteEduTask(task: RemoteEduTask, files: List): Result { + LOG.info("Submitting RemoteEduTask: ${task.name} (id=${task.id}), checkProfile='${task.checkProfile}'") + if (task.checkProfile.isEmpty()) { + val message = "Check profile is empty for task ${task.name}. Submission aborted to avoid server error." + LOG.error(message) + return Err(message + " Please try to 'Synchronize project' or contact support.") + } val connector = HyperskillConnector.getInstance() val attempt = connector.postAttempt(task).onError { return Err(it) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlDeepLoader.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlDeepLoader.kt index 14cd57948..dccdabe91 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlDeepLoader.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlDeepLoader.kt @@ -157,15 +157,23 @@ object YamlDeepLoader { } } - @Throws(RemoteYamlLoadingException::class) fun Course.loadRemoteInfoRecursively(project: Project) { - loadRemoteInfo(project) - sections.forEach { section -> section.loadRemoteInfo(project) } + fun StudyItem.loadRemoteInfoSafe(project: Project) { + try { + loadRemoteInfo(project) + } + catch (e: RemoteYamlLoadingException) { + LOG.warn(e) + } + } + + loadRemoteInfoSafe(project) + sections.forEach { section -> section.loadRemoteInfoSafe(project) } // top-level and from sections visitLessons { lesson -> - lesson.loadRemoteInfo(project) - lesson.taskList.forEach { task -> task.loadRemoteInfo(project) } + lesson.loadRemoteInfoSafe(project) + lesson.taskList.forEach { task -> task.loadRemoteInfoSafe(project) } } } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlFormatSynchronizer.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlFormatSynchronizer.kt index cadc504ca..3347708a5 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlFormatSynchronizer.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlFormatSynchronizer.kt @@ -51,6 +51,7 @@ import javax.swing.JLabel import javax.swing.JPanel object YamlFormatSynchronizer { + private val LOG = com.intellij.openapi.diagnostic.logger() val LOAD_FROM_CONFIG = Key("Hyperskill.loadItem") val SAVE_TO_CONFIG = Key("Hyperskill.saveItem") @@ -146,13 +147,7 @@ object YamlFormatSynchronizer { } private fun StudyItem.saveConfig(project: Project, configName: String, mapper: ObjectMapper) { - val dir = try { - getConfigDir(project) - } - catch (e: IllegalStateException) { - // Config dir not found - item was probably deleted from filesystem - return - } + val dir = getConfigDir(project) ?: return val configFile = runReadAction { dir.findChild(configName) } if (configFile?.getUserData(SAVE_TO_CONFIG) == false) return @@ -185,6 +180,7 @@ object YamlFormatSynchronizer { ) } val yamlText = mapper.writeValueAsString(this) + LOG.info("Saving item ${this.name} to ${file.name}. YAML content snippet: ${yamlText.take(200)}") val formattedYamlText = reformatYaml(project, file.name, yamlText) VfsUtil.saveText(file, formattedYamlText) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt index a0b9f7f05..09517533e 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt @@ -11,6 +11,7 @@ import org.hyperskill.academy.learning.courseFormat.* import org.hyperskill.academy.learning.courseFormat.ext.customContentPath import org.hyperskill.academy.learning.courseFormat.ext.getDir import org.hyperskill.academy.learning.courseFormat.ext.getPathToChildren +import org.hyperskill.academy.learning.courseFormat.tasks.RemoteEduTask import org.hyperskill.academy.learning.courseFormat.tasks.Task import org.hyperskill.academy.learning.messages.EduCoreBundle import org.hyperskill.academy.learning.storage.persistEduFiles @@ -55,6 +56,9 @@ object YamlLoader { val existingItem = getStudyItemForConfig(project, configFile) val deserializedItem = deserializeItemProcessingErrors(configFile, project, loadFromVFile, mapper) ?: return + if (deserializedItem is Task) { + com.intellij.openapi.diagnostic.logger().info("Deserialized task ${deserializedItem.name} (type=${deserializedItem.itemType}), checkProfile='${(deserializedItem as? RemoteEduTask)?.checkProfile}'") + } val customContentPath = existingItem?.course.customContentPath deserializedItem.ensureChildrenExist(configFile.parent, customContentPath) @@ -108,7 +112,7 @@ object YamlLoader { fun StudyItem.getConfigFileForChild(project: Project, childName: String): VirtualFile? { val courseDir = project.courseDir - val dir = getDir(courseDir) ?: error(noDirForItemMessage(name)) + val dir = getDir(courseDir) ?: return null val itemDir = dir.findFileByRelativePath(getPathToChildren())?.findChild(childName) val configFile = childrenConfigFileNames.map { itemDir?.findChild(it) }.firstOrNull { it != null } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt index 52007343a..55328bc4f 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt @@ -15,7 +15,9 @@ import org.hyperskill.academy.learning.yaml.format.TaskChangeApplier class StudentTaskChangeApplier(project: Project) : TaskChangeApplier(project) { private val LOG = logger() override fun applyChanges(existingItem: Task, deserializedItem: Task) { - LOG.info("Applying changes for task: ${existingItem} - ${deserializedItem}") + LOG.info("Applying changes for task: ${existingItem.name} (id=${existingItem.id})") + LOG.info("Existing item checkProfile: ${(existingItem as? RemoteEduTask)?.checkProfile}") + LOG.info("Deserialized item checkProfile: ${(deserializedItem as? RemoteEduTask)?.checkProfile}") if (existingItem.solutionHidden != deserializedItem.solutionHidden && !ApplicationManager.getApplication().isInternal) { throw YamlLoadingException(EduCoreBundle.message("yaml.editor.invalid.visibility.cannot.be.changed")) } @@ -26,14 +28,14 @@ class StudentTaskChangeApplier(project: Project) : TaskChangeApplier(project) { existingItem.feedback = deserializedItem.feedback // Note: record is no longer serialized (legacy field), don't overwrite existing value - when (existingItem) { - is EduTask -> { - if (existingItem is RemoteEduTask) { - val newCheckProfile = (deserializedItem as RemoteEduTask).checkProfile - if (newCheckProfile.isNotEmpty()) { - existingItem.checkProfile = newCheckProfile - } - } + if (existingItem is RemoteEduTask && deserializedItem is RemoteEduTask) { + val newCheckProfile = deserializedItem.checkProfile + if (newCheckProfile != existingItem.checkProfile) { + LOG.info("Updating checkProfile for task ${existingItem.name}: '${existingItem.checkProfile}' -> '$newCheckProfile'") + existingItem.checkProfile = newCheckProfile + } + if (existingItem.checkProfile.isEmpty()) { + LOG.warn("checkProfile is empty for RemoteEduTask ${existingItem.name} after applying changes") } } } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/utils.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/utils.kt index 05c9babfd..2bf4c8926 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/utils.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/utils.kt @@ -16,11 +16,10 @@ import org.hyperskill.academy.learning.yaml.YamlConfigSettings.remoteConfigFileN * In most cases it's the same as [getDir] except the case when it's [Task] inside [FrameworkLesson] in student mode. * In this case, all meta files, including config ones, are stored separately */ -fun StudyItem.getConfigDir(project: Project): VirtualFile { - val configDir = if (this is Task) getTaskDirectory(project) else getDir(project.courseDir) - return configDir ?: error("Config dir for `$name` not found") +fun StudyItem.getConfigDir(project: Project): VirtualFile? { + return if (this is Task) getTaskDirectory(project) else getDir(project.courseDir) } fun StudyItem.remoteConfigFile(project: Project): VirtualFile? { - return getConfigDir(project).findChild(remoteConfigFileName) + return getConfigDir(project)?.findChild(remoteConfigFileName) } diff --git a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/coursecreator/yaml/YamlSerializationTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/coursecreator/yaml/YamlSerializationTest.kt index 609b1eb1c..32f3e2d7c 100644 --- a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/coursecreator/yaml/YamlSerializationTest.kt +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/coursecreator/yaml/YamlSerializationTest.kt @@ -50,6 +50,7 @@ class YamlSerializationTest : YamlTestCase() { |- name: Main.go | visible: true | learner_created: false + |check_profile: $checkProfile |status: Unchecked |record: -1 |""".trimMargin() diff --git a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/yaml/YamlChangedAfterEventTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/yaml/YamlChangedAfterEventTest.kt index 2b0609672..f4ba3f56c 100644 --- a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/yaml/YamlChangedAfterEventTest.kt +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/yaml/YamlChangedAfterEventTest.kt @@ -52,7 +52,7 @@ class YamlChangedAfterEventTest : YamlTestCase() { private fun checkConfig(item: StudyItem, expectedConfig: String) { UIUtil.dispatchAllInvocationEvents() - val configFile = item.getConfigDir(project).findChild(item.configFileName) + val configFile = item.getConfigDir(project)?.findChild(item.configFileName) ?: error("No config file for item: ${item::class.simpleName} ${item.name}") val document = FileDocumentManager.getInstance().getDocument(configFile)!! From f26db60c0cc16b41252a63c08b0e9ae7c2bc222e Mon Sep 17 00:00:00 2001 From: Oleksandr Liemiahov Date: Tue, 12 May 2026 11:14:31 +0300 Subject: [PATCH 4/6] fix opening projects. --- .../courseFormat/tasks/RemoteEduTask.kt | 7 +++++- .../learning/projectView/CourseViewUtils.kt | 14 ++++++----- .../HyperskillSelectTrackPanel.kt | 2 +- .../academy/learning/yaml/YamlLoader.kt | 25 ++++++++++++++++++- .../academy/learning/courseView/NodesTest.kt | 25 +++++++++++++++++++ 5 files changed, 64 insertions(+), 9 deletions(-) diff --git a/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/tasks/RemoteEduTask.kt b/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/tasks/RemoteEduTask.kt index 8eb049168..de9b34f44 100644 --- a/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/tasks/RemoteEduTask.kt +++ b/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/tasks/RemoteEduTask.kt @@ -1,6 +1,7 @@ package org.hyperskill.academy.learning.courseFormat.tasks import org.hyperskill.academy.learning.courseFormat.CheckStatus +import org.hyperskill.academy.learning.courseFormat.logger import org.jetbrains.annotations.NonNls import java.util.* @@ -8,6 +9,10 @@ import java.util.* * By default, EduTask is checked with local tests, but if `is_remote_tested` flag is true, then we should send code to remote check */ // see EDU-4504 Support Go edu problems + + +private val LOG = logger() + class RemoteEduTask : EduTask { constructor() constructor(name: String, id: Int, position: Int, updateDate: Date, status: CheckStatus) : super(name, id, position, updateDate, status) @@ -19,7 +24,7 @@ class RemoteEduTask : EduTask { var checkProfile: String = "" set(value) { if (field != value) { - println("[DEBUG_LOG] RemoteEduTask.checkProfile set to '$value' (was '$field')") + LOG.info("[DEBUG_LOG] RemoteEduTask.checkProfile set to '$value' (was '$field')") } field = value } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/CourseViewUtils.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/CourseViewUtils.kt index 999131757..7009a723f 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/CourseViewUtils.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/CourseViewUtils.kt @@ -6,6 +6,7 @@ import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.vfs.VfsUtilCore.getRelativePath +import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.* import com.intellij.ui.LayeredIcon import org.hyperskill.academy.EducationalCoreIcons.CourseView @@ -57,8 +58,10 @@ object CourseViewUtils { private fun isShowDirInView(project: Project, task: Task, dir: PsiDirectory): Boolean { if (dir.children.isEmpty()) return true val dirName = dir.name - val hasTaskFileNotInsideSourceDir = task.hasVisibleTaskFilesNotInsideSourceDir(project) - if (dirName == task.sourceDir) return hasTaskFileNotInsideSourceDir + if (dirName == task.sourceDir) { + val taskDir = dir.virtualFile.parent ?: return false + return task.hasVisibleTaskFilesNotInsideSourceDir(taskDir) + } return task.taskFiles.values.any { if (!it.isVisible || isTestFile(task, it.name)) return@any false val virtualFile = it.getVirtualFile(project) ?: return@any false @@ -66,12 +69,11 @@ object CourseViewUtils { } } - private fun Task.hasVisibleTaskFilesNotInsideSourceDir(project: Project): Boolean { - val taskDir = getDir(project.courseDir) ?: error("Directory for task $name not found") + private fun Task.hasVisibleTaskFilesNotInsideSourceDir(taskDir: VirtualFile): Boolean { val sourceDir = findSourceDir(taskDir) ?: return false return taskFiles.values.any { if (!it.isVisible || isTestFile(this, it.name)) return@any false - val virtualFile = it.getVirtualFile(project) + val virtualFile = it.findTaskFileInDir(taskDir) if (virtualFile == null) { Logger.getInstance(Task::class.java).warn("VirtualFile for ${it.name} not found") return@any false @@ -98,7 +100,7 @@ object CourseViewUtils { val vFile = baseDir.virtualFile val sourceVFile = vFile.findFileByRelativePath(sourceDirName) ?: return baseDir - if (task.hasVisibleTaskFilesNotInsideSourceDir(project)) { + if (task.hasVisibleTaskFilesNotInsideSourceDir(vFile)) { return baseDir } return PsiManager.getInstance(project).findDirectory(sourceVFile) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/newProjectUI/HyperskillSelectTrackPanel.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/newProjectUI/HyperskillSelectTrackPanel.kt index d3afc4f16..7bc739966 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/newProjectUI/HyperskillSelectTrackPanel.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/newProjectUI/HyperskillSelectTrackPanel.kt @@ -42,7 +42,7 @@ class HyperskillSelectTrackPanel : JPanel(BorderLayout()) { } row { button(EduCoreBundle.message("course.dialog.hyperskill.jba.on.hyperskill.select.course")) { - BrowserUtil.open("https://hyperskill.org/courses") + BrowserUtil.open("https://hyperskill.org/courses?utm_source=plugin_ha&utm_medium=plugin&utm_campaign=ha") val dialog = UIUtil.getParentOfType(DialogWrapperDialog::class.java, this@HyperskillSelectTrackPanel) dialog?.dialogWrapper?.close(DialogWrapper.OK_EXIT_CODE) }.applyToComponent { diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt index 09517533e..070f41295 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt @@ -2,17 +2,21 @@ package org.hyperskill.academy.learning.yaml import com.fasterxml.jackson.databind.ObjectMapper import com.google.common.annotations.VisibleForTesting +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import com.intellij.util.messages.Topic import org.hyperskill.academy.learning.* +import org.hyperskill.academy.learning.api.EduOAuthCodeFlowConnector import org.hyperskill.academy.learning.courseFormat.* import org.hyperskill.academy.learning.courseFormat.ext.customContentPath import org.hyperskill.academy.learning.courseFormat.ext.getDir import org.hyperskill.academy.learning.courseFormat.ext.getPathToChildren +import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse import org.hyperskill.academy.learning.courseFormat.tasks.RemoteEduTask import org.hyperskill.academy.learning.courseFormat.tasks.Task +import org.hyperskill.academy.learning.courseFormat.tasks.UnsupportedTask import org.hyperskill.academy.learning.messages.EduCoreBundle import org.hyperskill.academy.learning.storage.persistEduFiles import org.hyperskill.academy.learning.yaml.YamlConfigSettings.configFileName @@ -25,14 +29,21 @@ import org.hyperskill.academy.learning.yaml.errorHandling.* import org.hyperskill.academy.learning.yaml.format.YamlMixinNames.TASK import org.hyperskill.academy.learning.yaml.format.getChangeApplierForItem import org.jetbrains.annotations.NonNls +import java.net.HttpURLConnection.HTTP_BAD_REQUEST +import java.net.HttpURLConnection.HTTP_FORBIDDEN +import java.net.HttpURLConnection.HTTP_UNAUTHORIZED /** * Get fully-initialized [StudyItem] object from yaml config file. * Uses [deserializeItemProcessingErrors] to deserialize object, than applies changes to existing object, see [loadItem]. */ object YamlLoader { + @PublishedApi + internal val LOG = Logger.getInstance(YamlLoader::class.java) + @NonNls private const val TOPIC = "Loaded YAML" + val YAML_LOAD_TOPIC: Topic = Topic.create(TOPIC, YamlListener::class.java) fun loadItem(project: Project, configFile: VirtualFile, loadFromVFile: Boolean) { @@ -93,7 +104,7 @@ object YamlLoader { } } - inline fun StudyItem.deserializeContent( + internal inline fun StudyItem.deserializeContent( project: Project, contentList: List, mapper: ObjectMapper = basicMapper(), @@ -102,6 +113,12 @@ object YamlLoader { for (titledItem in contentList) { val configFile: VirtualFile = getConfigFileForChild(project, titledItem.name) ?: continue val deserializeItem = deserializeItemProcessingErrors(configFile, project, mapper = mapper, parentItem = this) as? T ?: continue + if (this is Lesson && isHyperskillTopicsLesson() && deserializeItem is UnsupportedTask) { + LOG.warn( + "Skipping unsupported task `${titledItem.name}` while loading Hyperskill topic lesson `${name}` from ${configFile.path}" + ) + continue + } deserializeItem.name = titledItem.name deserializeItem.index = titledItem.index content.add(deserializeItem) @@ -221,6 +238,12 @@ object YamlLoader { } +@PublishedApi +internal fun Lesson.isHyperskillTopicsLesson(): Boolean { + val section = parentOrNull as? Section ?: return false + return course is HyperskillCourse && section.presentableName == EduFormatNames.HYPERSKILL_TOPICS +} + private fun StudyItem.ensureChildrenExist(itemDir: VirtualFile, customContentPath: String) { when (this) { is ItemContainer -> { diff --git a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/courseView/NodesTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/courseView/NodesTest.kt index 0c7a00b70..4e07c9f80 100644 --- a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/courseView/NodesTest.kt +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/courseView/NodesTest.kt @@ -1,10 +1,14 @@ // Copyright 2000-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. package org.hyperskill.academy.learning.courseView +import com.intellij.psi.PsiManager +import org.hyperskill.academy.learning.courseDir import org.hyperskill.academy.learning.configurators.FakeGradleBasedLanguage import org.hyperskill.academy.learning.courseFormat.CheckStatus import org.hyperskill.academy.learning.courseFormat.CourseMode +import org.hyperskill.academy.learning.courseFormat.ext.getDir import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse +import org.hyperskill.academy.learning.projectView.CourseViewUtils import org.junit.Test class NodesTest : CourseViewTestBase() { @@ -41,6 +45,27 @@ class NodesTest : CourseViewTestBase() { ) } + @Test + fun `test task directory lookup does not require task getDir`() { + courseWithFiles(language = FakeGradleBasedLanguage) { + lesson { + eduTask { + taskFile("src/file.txt") + } + } + } + + val task = findTask(0, 0) + val taskDir = task.getDir(project.courseDir)!! + val taskPsiDir = PsiManager.getInstance(project).findDirectory(taskDir)!! + + task.name = "missingTask" + assertNull(task.getDir(project.courseDir)) + + val directory = CourseViewUtils.findTaskDirectory(project, taskPsiDir, task) + assertEquals("src", directory?.name) + } + @Test fun testSections() { courseWithFiles { From 5268dc97ec70a4c4919df5d62cb9711c4346dbc2 Mon Sep 17 00:00:00 2001 From: Oleksandr Liemiahov Date: Tue, 12 May 2026 18:28:09 +0300 Subject: [PATCH 5/6] potential fix python checker error --- documentation/CheckAction.md | 296 ++++++++++++++++++ documentation/TaskTypeDetermination.md | 279 +++++++++++++++++ .../hyperskill/api/HyperskillConnector.kt | 9 + .../HyperskillSubmissionFactory.kt | 6 +- .../HyperskillCreateSubmissionTest.kt | 5 +- 5 files changed, 592 insertions(+), 3 deletions(-) create mode 100644 documentation/CheckAction.md create mode 100644 documentation/TaskTypeDetermination.md diff --git a/documentation/CheckAction.md b/documentation/CheckAction.md new file mode 100644 index 000000000..45cbe8491 --- /dev/null +++ b/documentation/CheckAction.md @@ -0,0 +1,296 @@ +## CheckAction + +This document explains how `org.hyperskill.academy.learning.actions.CheckAction` works, what code paths it executes, and how local and remote checking are combined. + +### Entry point + +The action is registered in `intellij-plugin/hs-core/resources/META-INF/hs-core.xml`: + +```xml + +``` + +The implementation is in `intellij-plugin/hs-core/src/org/hyperskill/academy/learning/actions/CheckAction.kt`. + +### High-level flow + +`CheckAction.actionPerformed(...)` does the following: + +1. Retrieves `Project` from the action event. +2. Refuses to run while indexing is active (`DumbService.isDumb(project)`). +3. Clears the check details view. +4. Saves all open documents. +5. Reads the current task from `TaskToolWindowView`. +6. Acquires a per-project lock so only one check can run at a time. +7. Calls all registered `CheckListener.beforeCheck(...)`. +8. Starts a background task `StudyCheckTask`. + +Relevant code: + +- `CheckAction.kt`, `actionPerformed` +- `CheckAction.kt`, `CheckActionState` + +### Core execution graph + +```text +CheckAction.actionPerformed + -> CheckListener.beforeCheck(project, task) + -> StudyCheckTask.run(indicator) + -> localCheck(indicator) + -> recreateTestFiles(taskDir) + -> maybe createTests(invisibleTestFiles) + -> checker.check(indicator) + -> if local result is Failed: stop + -> remoteCheckerForTask(project, task) + -> remoteChecker?.check(project, task, indicator) ?: localResult + -> onSuccess() + -> update task.status + -> update task.feedback + -> saveItem(task) + -> checker.onTaskSolved() / checker.onTaskFailed() + -> TaskToolWindowView.checkFinished(...) + -> CheckListener.afterCheck(project, task, result) +``` + +### How the local checker is chosen + +`StudyCheckTask` creates a local checker through the course configurator: + +```kotlin +val configurator = task.course.configurator +checker = configurator?.taskCheckerProvider?.getTaskChecker(task, project) +``` + +The generic selection logic is in `intellij-plugin/hs-core/src/org/hyperskill/academy/learning/checker/TaskCheckerProvider.kt`. + +Important behavior: + +- `RemoteEduTask` -> no local checker +- `CodeTask` -> no local checker +- `TheoryTask` -> no local checker +- `UnsupportedTask` -> no local checker +- `EduTask` -> configurator-specific local checker +- `OutputTask` -> `OutputTaskChecker` +- `IdeTask` -> `IdeTaskChecker` + +This is why not every task goes through local tests. + +### What `localCheck(...)` does + +`StudyCheckTask.localCheck(...)` performs the local phase: + +1. If no checker exists, returns `CheckResult.NO_LOCAL_CHECK`. +2. Resolves the task directory. +3. Recreates test files from task metadata before running checks. +4. For non-Hyperskill courses, restores invisible test files into the project tree. +5. Calls `checker.check(indicator)`. + +Relevant methods: + +- `CheckAction.kt`, `localCheck` +- `CheckAction.kt`, `recreateTestFiles` +- `CheckAction.kt`, `createTests` + +### Why test files are recreated + +Before running a local check, the plugin restores author-provided test files from the task model. This prevents a learner from changing, deleting, or corrupting tests to fake a successful check. + +For framework lessons, the plugin uses `FrameworkLessonManager` cached original test files rather than the current `task.taskFiles`, because framework tasks may otherwise carry stale test data from another stage. + +### How local test execution works + +The standard base implementation for local `EduTask` checking is `EduTaskCheckerBase` in: + +`intellij-plugin/hs-core/src/org/hyperskill/academy/learning/checker/EduTaskCheckerBase.kt` + +Its `check(...)` method: + +1. Hides the Run tool window. +2. Calls `EnvironmentChecker.getEnvironmentError(project, task)`. +3. Builds run configurations. +4. Validates each configuration. +5. Executes them using IntelliJ run infrastructure. +6. Collects test results. +7. Produces a `CheckResult`. + +If execution starts but tests do not actually run, subclasses may translate stderr into a more specific error result. + +### How run configurations are built and executed + +The utility layer is in: + +`intellij-plugin/hs-core/src/org/hyperskill/academy/learning/checker/CheckUtils.kt` + +Key pieces: + +- `getCustomRunConfigurationForChecker(...)` +- `createDefaultRunConfiguration(...)` +- `executeRunConfigurations(...)` + +Execution details: + +- A custom task-specific run configuration in `.idea/runConfigurations` is preferred if present. +- Otherwise, IntelliJ derives temporary run configurations from PSI context. +- Configurations run through `ProgramRunner` and `ExecutionEnvironmentBuilder`. +- The plugin tracks all started environments and waits on a `CountDownLatch`. +- Test results are collected through a `TestResultCollector`. + +### Local-vs-remote ordering + +`StudyCheckTask.run(...)` always does the local phase first: + +```kotlin +val localCheckResult = localCheck(indicator) +if (localCheckResult.status === CheckStatus.Failed) { + result = localCheckResult + return +} +val remoteChecker = remoteCheckerForTask(project, task) +result = remoteChecker?.check(project, task, indicator) ?: localCheckResult +``` + +This means: + +- A local `Failed` result stops the pipeline immediately. +- Remote checking is attempted only if local checking did not fail. +- If there is no remote checker, the final result is the local result. + +### How the remote checker is chosen + +Remote checkers are selected through the extension point: + +`HyperskillEducational.remoteTaskChecker` + +The manager is: + +`intellij-plugin/hs-core/src/org/hyperskill/academy/learning/checker/remote/RemoteTaskCheckerManager.kt` + +It: + +1. Collects all registered remote checkers. +2. Filters them by `canCheck(project, task)`. +3. Returns exactly one checker or `null`. +4. Throws if more than one checker matches. + +### Hyperskill remote checker + +Hyperskill registers: + +```xml + +``` + +Implementation: + +`intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/checker/HyperskillRemoteTaskChecker.kt` + +It can check only when: + +- `task.course is HyperskillCourse` +- `HyperskillCheckConnector.isRemotelyChecked(task)` is true + +That remote set is: + +- `CodeTask` +- `RemoteEduTask` +- `UnsupportedTask` + +### Hyperskill remote task behavior by type + +#### CodeTask + +Path: + +- `HyperskillRemoteTaskChecker.check(...)` +- `HyperskillCheckConnector.checkCodeTask(...)` + +Behavior: + +1. Validates that task id exists. +2. Tries websocket-based check session. +3. If websocket path fails, falls back to HTTP submission. +4. Polls the submission until status changes from `evaluation`. + +#### RemoteEduTask + +Path: + +- `HyperskillRemoteTaskChecker.check(...)` +- `HyperskillCheckConnector.checkRemoteEduTask(...)` + +Behavior: + +1. Validates that task id exists. +2. Collects solution files. +3. Creates an attempt. +4. Creates and posts a submission. +5. Polls until final status is received. + +#### UnsupportedTask + +Path: + +- `HyperskillRemoteTaskChecker.check(...)` +- `HyperskillCheckConnector.checkUnsupportedTask(...)` + +Behavior: + +1. Does not create a new submission. +2. Loads existing submissions from the platform. +3. Derives solved/failed state from the latest known submissions. + +### Hyperskill local EduTask behavior + +Hyperskill `EduTask` is special: + +- It is checked locally. +- It does not use the remote checker. +- After the local result is finalized, `HyperskillCheckListener.afterCheck(...)` may asynchronously post the solution to Hyperskill. + +This listener is registered in `META-INF/Hyperskill.xml`. + +Implementation: + +`intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/checker/HyperskillCheckListener.kt` + +Behavior: + +- `beforeCheck(...)` updates Hyperskill metrics. +- `afterCheck(...)` restarts metrics for unsolved current tasks. +- For non-remote, non-theory Hyperskill tasks, it posts the local solution to Hyperskill in background if the user is logged in. + +This postback is not the same thing as remote checking. It is a follow-up side effect after local checking has already completed. + +### Result finalization + +When background execution succeeds, `StudyCheckTask.onSuccess()`: + +1. Stores `task.status = checkResult.status` +2. Stores `task.feedback` +3. Persists the task via `saveItem(task)` +4. Calls checker lifecycle hooks +5. Updates the tool window +6. Refreshes course progress and project view +7. Calls all `CheckListener.afterCheck(...)` + +### Error and cancel behavior + +If the background task is cancelled: + +- the tool window is reset to `readyToCheck()` + +If an exception happens: + +- refresh-token failure is converted to `failedToSubmit(...)` +- everything else becomes generic `failedToCheck` + +### Practical summary + +For the common Hyperskill task classes: + +- `EduTask`: local tests first, then optional async postback to Hyperskill +- `RemoteEduTask`: remote-only check +- `CodeTask`: remote-only check +- `UnsupportedTask`: remote state lookup only, no fresh submission + +This local-first and then maybe-remote pattern is the most important rule to keep in mind when debugging `CheckAction`. diff --git a/documentation/TaskTypeDetermination.md b/documentation/TaskTypeDetermination.md new file mode 100644 index 000000000..160ea94cb --- /dev/null +++ b/documentation/TaskTypeDetermination.md @@ -0,0 +1,279 @@ +## Task Type Determination + +This document explains where task class selection happens and why a task becomes `EduTask`, `RemoteEduTask`, `CodeTask`, `TheoryTask`, or `UnsupportedTask`. + +There are two main scenarios: + +1. building tasks from Hyperskill/Stepik server responses +2. loading tasks from serialized local course data (JSON or YAML) + +### Two different type systems + +The codebase works with two related but distinct type concepts. + +#### Remote step block type + +This comes from Stepik/Hyperskill API and lives in `stepSource.block.name`. + +Typical values: + +- `pycharm` +- `code` +- `text` +- other Stepik/Hyperskill values like `choice`, `sorting`, `matching`, and so on + +See: + +- `intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/StepikSteps.kt` +- `hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/hyperskill/HyperskillTaskType.kt` + +#### Plugin task type + +This is the internal task class identifier stored in `Task.itemType`. + +Typical values: + +- `edu` +- `remote_edu` +- `code` +- `theory` +- `unsupported` + +These values determine which Kotlin task class is instantiated during deserialization and how checking behaves later. + +### Where runtime Hyperskill tasks are determined + +The main runtime decision point is: + +`intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/courseGeneration/HyperskillTaskBuilder.kt` + +The crucial method is `build()`: + +```kotlin +fun build(): Task? = when (val blockName = stepSource.block?.name) { + EduTask.PYCHARM_TASK_TYPE if stepSource.isRemoteTested -> createTask(RemoteEduTask.REMOTE_EDU_TASK_TYPE) + EduTask.PYCHARM_TASK_TYPE, + "text", + CodeTask.CODE_TASK_TYPE -> createTask(blockName) + else -> null +} +``` + +This means: + +- `block.name == "pycharm"` and `is_remote_tested == true` -> `RemoteEduTask` +- `block.name == "pycharm"` and `is_remote_tested == false` -> continue as plain `pycharm` +- `block.name == "code"` -> `CodeTask` +- `block.name == "text"` -> `TheoryTask` + +### Why `pycharm` often becomes `EduTask` + +The next step is delegation to: + +`intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/StepikTaskBuilder.kt` + +`HyperskillTaskBuilder.createTask(type)` calls `super.createTask(type)`. + +Inside `StepikTaskBuilder` there are two layers: + +1. mapping remote Stepik/Hyperskill task names to builders +2. mapping plugin task type strings to actual task classes + +Important pieces: + +- `stepikTaskBuilders` +- `pluginTaskTypes` +- `pycharmTask(type: String? = null)` + +For `pycharm`, the builder goes into `pycharmTask()`: + +```kotlin +HyperskillTaskType.PYCHARM -> { _: String -> pycharmTask() } +``` + +Then: + +```kotlin +val taskType = type ?: stepOptions.taskType +val task = pluginTaskTypes[taskType]?.invoke(taskName) + ?: EduTask(taskName, stepId, stepPosition, updateDate, CheckStatus.Unchecked) +``` + +This fallback is the key reason many programming tasks become `EduTask`. + +If: + +- no explicit override type was passed, and +- `stepOptions.taskType` is missing, or +- `stepOptions.taskType` is `pycharm` + +then `pluginTaskTypes[taskType]` does not resolve to a dedicated class, and the fallback is `EduTask`. + +### Why `RemoteEduTask` exists at all + +`RemoteEduTask` is a special subclass of `EduTask` used when a programming task looks like a `pycharm` task structurally, but Hyperskill wants it checked remotely instead of by local tests. + +That decision is made by: + +- `stepSource.block.name == "pycharm"` +- `stepSource.isRemoteTested == true` + +See `HyperskillStepSource.isRemoteTested` in: + +`intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/api/hyperskillAPI.kt` + +This is the field that flips a regular programming-style task from local `EduTask` behavior into remote `RemoteEduTask` behavior. + +### How `checkProfile` gets attached + +Once the task instance is created, `HyperskillTaskBuilder.createTask(...)` fills Hyperskill-specific properties: + +```kotlin +is EduTask -> { + if (task is RemoteEduTask) { + task.checkProfile = stepSource.checkProfile + } + name = stepSource.title +} +``` + +So `checkProfile` is not what decides the class. It is attached after the class has already been decided. + +### CodeTask path + +`CodeTask` is chosen when the server step block type is exactly `code`. + +That is a separate family from `pycharm` tasks: + +- `code` -> `CodeTask` +- `pycharm` -> `EduTask` or `RemoteEduTask` + +This distinction matters for checking: + +- `CodeTask` is remote-checked on Hyperskill +- `EduTask` is locally checked +- `RemoteEduTask` is remote-checked + +### TheoryTask path + +`TheoryTask` is chosen when `block.name == "text"`. + +That is handled by passing `"text"` into `StepikTaskBuilder.createTask(...)`, which maps through `HyperskillTaskType.TEXT` to `pycharmTask(THEORY_TASK_TYPE)`. + +### UnsupportedTask path + +In generic Stepik/Hyperskill mapping, unsupported step types are represented as `UnsupportedTask`. + +The fallback builder is: + +```kotlin +else -> this::unsupportedTask +``` + +However, note that `HyperskillTaskBuilder.build()` itself currently filters accepted `block.name` values quite aggressively and may return `null` instead of building unsupported tasks in some paths. The generic `StepikTaskBuilder` still contains the broader unsupported-type behavior. + +### Where serialized type is stored + +Every task class defines its own `itemType`. + +Examples: + +- `EduTask.itemType = "edu"` +- `RemoteEduTask.itemType = "remote_edu"` +- `CodeTask.itemType = "code"` + +Files: + +- `hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/tasks/EduTask.kt` +- `hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/tasks/RemoteEduTask.kt` +- `hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/tasks/CodeTask.kt` + +When task metadata is serialized into Stepik-style step options, `PyCharmStepOptions.taskType` is populated from `task.itemType`: + +```kotlin +taskType = task.itemType +``` + +See: + +`intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/StepikSteps.kt` + +### JSON deserialization path + +When loading local JSON, task type is determined directly from serialized `task_type`, not from `block.name`. + +See: + +`hs-edu-format/src/org/hyperskill/academy/learning/json/mixins/LocalEduCourseMixins.kt` + +The key dispatch function is `deserializeTask(...)`. + +Mapping: + +- `ide` -> `IdeTask` +- `theory` -> `TheoryTask` +- `code` -> `CodeTask` +- `edu` or deprecated `pycharm` -> `EduTask` +- `output` -> `OutputTask` +- `remote_edu` -> `RemoteEduTask` +- `unsupported` -> `UnsupportedTask` + +The special compatibility rule is: + +```kotlin +EduTask.EDU_TASK_TYPE, EduTask.PYCHARM_TASK_TYPE -> EduTask::class.java +``` + +So old saved `pycharm` tasks are intentionally loaded as `EduTask`. + +### YAML deserialization path + +When loading YAML, task class is chosen from the `type` field. + +See: + +`hs-edu-format/src/org/hyperskill/academy/learning/yaml/YamlDeserializer.kt` + +Mapping: + +- `edu` -> `EduTask` +- `remote_edu` -> `RemoteEduTask` +- `output` -> `OutputTask` +- `theory` -> `TheoryTask` +- `ide` -> `IdeTask` +- `code` -> `CodeTask` +- `unsupported` -> `UnsupportedTask` + +Again, this is direct class selection from serialized data, not inference from remote API step structure. + +### Practical rules + +When debugging “why is this task an `EduTask`?” use these rules: + +#### If the task came from Hyperskill API: + +- `block.name == "pycharm"` and `is_remote_tested == false` -> `EduTask` +- `block.name == "pycharm"` and `is_remote_tested == true` -> `RemoteEduTask` +- `block.name == "code"` -> `CodeTask` +- `block.name == "text"` -> `TheoryTask` + +#### If the task came from saved JSON/YAML: + +- class comes from serialized type field directly +- old `pycharm` serialized tasks are loaded as `EduTask` + +### Why this split exists + +Historically, `pycharm` was the generic programming-task format used by the plugin. Later, the plugin introduced internal task classes such as `EduTask` and `RemoteEduTask` with different checking semantics. + +As a result: + +- remote API payloads still often use `pycharm` +- internal model prefers `edu` / `remote_edu` +- deserializers keep backward compatibility with older `pycharm` task records + +That is why task type determination can look inconsistent until you separate: + +1. server block type +2. internal task class type +3. serialized local task type diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/api/HyperskillConnector.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/api/HyperskillConnector.kt index 6a9ef4d12..1b22c9551 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/api/HyperskillConnector.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/api/HyperskillConnector.kt @@ -9,6 +9,7 @@ import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.project.Project import okhttp3.* +import okio.Buffer import org.apache.http.client.utils.URIBuilder import org.hyperskill.academy.learning.* import org.hyperskill.academy.learning.api.EduOAuthCodeFlowConnector @@ -81,6 +82,14 @@ abstract class HyperskillConnector : EduOAuthCodeFlowConnector, feedback: String): StepikBasedSubmission { val reply = EduTaskReply() - reply.feedback = Feedback(feedback) reply.score = if (task.status == CheckStatus.Solved) "1" else "0" reply.solution = files + reply.feedback = Feedback(feedback) + reply.checkProfile = "" return StepikBasedSubmission(attempt, reply) } fun createRemoteEduTaskSubmission(task: RemoteEduTask, attempt: Attempt, files: List): StepikBasedSubmission { val reply = EduTaskReply() - reply.checkProfile = task.checkProfile + reply.score = if (task.status == CheckStatus.Solved) "1" else "0" reply.solution = files + reply.checkProfile = task.checkProfile return StepikBasedSubmission(attempt, reply) } } \ No newline at end of file diff --git a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/stepik/hyperskill/HyperskillCreateSubmissionTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/stepik/hyperskill/HyperskillCreateSubmissionTest.kt index af71b5050..85962b1d9 100644 --- a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/stepik/hyperskill/HyperskillCreateSubmissionTest.kt +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/stepik/hyperskill/HyperskillCreateSubmissionTest.kt @@ -56,6 +56,7 @@ class HyperskillCreateSubmissionTest : EduTestCase() { | is_visible: true | - name: src/Test.kt | is_visible: false + | check_profile: "" | """.trimMargin() ) @@ -82,6 +83,7 @@ class HyperskillCreateSubmissionTest : EduTestCase() { | is_visible: true | - name: src/Test.kt | is_visible: false + | check_profile: "" | """.trimMargin() ) @@ -100,6 +102,7 @@ class HyperskillCreateSubmissionTest : EduTestCase() { |attempt: 12345 |reply: | version: $JSON_FORMAT_VERSION + | score: "" | solution: | - name: src/Task.kt | is_visible: true @@ -134,4 +137,4 @@ class HyperskillCreateSubmissionTest : EduTestCase() { val actual = YamlMapper.basicMapper().writeValueAsString(submission) assertEquals(expected, actual) } -} \ No newline at end of file +} From 0ecdc84bc422a5d56ca44d8ae83310b66f6356b6 Mon Sep 17 00:00:00 2001 From: Oleksandr Liemiahov Date: Tue, 12 May 2026 23:31:07 +0300 Subject: [PATCH 6/6] review fix --- .../courseFormat/tasks/RemoteEduTask.kt | 12 +----- .../format/remote/RemoteEduTaskYamlMixin.kt | 2 +- .../messages/EduCoreBundle.properties | 3 +- .../hyperskill/api/HyperskillConnector.kt | 9 ----- .../checker/HyperskillSubmitConnector.kt | 12 ++++-- .../HyperskillSubmissionFactory.kt | 3 +- .../learning/yaml/YamlFormatSynchronizer.kt | 4 +- .../academy/learning/yaml/YamlLoader.kt | 12 +----- .../student/StudentTaskChangeApplier.kt | 14 +------ .../format/yaml/StudentChangeApplierTest.kt | 37 ++++++++++++++++++- .../HyperskillCreateSubmissionTest.kt | 2 - 11 files changed, 54 insertions(+), 56 deletions(-) diff --git a/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/tasks/RemoteEduTask.kt b/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/tasks/RemoteEduTask.kt index de9b34f44..da9448722 100644 --- a/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/tasks/RemoteEduTask.kt +++ b/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/tasks/RemoteEduTask.kt @@ -1,7 +1,6 @@ package org.hyperskill.academy.learning.courseFormat.tasks import org.hyperskill.academy.learning.courseFormat.CheckStatus -import org.hyperskill.academy.learning.courseFormat.logger import org.jetbrains.annotations.NonNls import java.util.* @@ -10,9 +9,6 @@ import java.util.* */ // see EDU-4504 Support Go edu problems - -private val LOG = logger() - class RemoteEduTask : EduTask { constructor() constructor(name: String, id: Int, position: Int, updateDate: Date, status: CheckStatus) : super(name, id, position, updateDate, status) @@ -22,12 +18,6 @@ class RemoteEduTask : EduTask { * MUST NOT BE HARDCODED */ var checkProfile: String = "" - set(value) { - if (field != value) { - LOG.info("[DEBUG_LOG] RemoteEduTask.checkProfile set to '$value' (was '$field')") - } - field = value - } override val itemType: String = REMOTE_EDU_TASK_TYPE @@ -35,4 +25,4 @@ class RemoteEduTask : EduTask { @NonNls const val REMOTE_EDU_TASK_TYPE: String = "remote_edu" } -} \ No newline at end of file +} diff --git a/hs-edu-format/src/org/hyperskill/academy/learning/yaml/format/remote/RemoteEduTaskYamlMixin.kt b/hs-edu-format/src/org/hyperskill/academy/learning/yaml/format/remote/RemoteEduTaskYamlMixin.kt index e9d83b661..942b24646 100644 --- a/hs-edu-format/src/org/hyperskill/academy/learning/yaml/format/remote/RemoteEduTaskYamlMixin.kt +++ b/hs-edu-format/src/org/hyperskill/academy/learning/yaml/format/remote/RemoteEduTaskYamlMixin.kt @@ -21,4 +21,4 @@ class RemoteEduTaskYamlMixin : StudentTaskYamlMixin() { @set:JsonProperty(CHECK_PROFILE) @get:JsonInclude(JsonInclude.Include.ALWAYS) var checkProfile: String = "" -} \ No newline at end of file +} diff --git a/intellij-plugin/hs-core/resources/messages/EduCoreBundle.properties b/intellij-plugin/hs-core/resources/messages/EduCoreBundle.properties index ca5a61a8c..41ab28e79 100644 --- a/intellij-plugin/hs-core/resources/messages/EduCoreBundle.properties +++ b/intellij-plugin/hs-core/resources/messages/EduCoreBundle.properties @@ -244,6 +244,7 @@ error.failed.to.post.solution=Failed to post solution error.failed.to.post.solution.to=Failed to post solution to {0} error.failed.to.post.solution.with.guide=Failed to post solution to {0}. For more information, \ see the Troubleshooting guide +hyperskill.error.empty.check.profile=Check profile is empty for task {0}. Please try to {1} or contact support. error.failed.to.refresh.tokens=Failed to refresh tokens error.invalid.rename.message=This rename operation can break the course @@ -617,4 +618,4 @@ yaml.editor.notification.directory.not.found=Directory for item ''{0}'' was not yaml.editor.notification.parameter.is.empty={0} is empty # {0} for course YAML version, {1} for supported YAML version yaml.version.compatibility.title=Course Created with Newer Plugin Version -yaml.version.compatibility.message=This course was created with a newer version of the plugin (YAML version {0}). The current plugin supports YAML version {1}. The course will be loaded in compatibility mode, and the YAML version will be automatically downgraded. Some features from the newer plugin version may not be available. \ No newline at end of file +yaml.version.compatibility.message=This course was created with a newer version of the plugin (YAML version {0}). The current plugin supports YAML version {1}. The course will be loaded in compatibility mode, and the YAML version will be automatically downgraded. Some features from the newer plugin version may not be available. diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/api/HyperskillConnector.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/api/HyperskillConnector.kt index 1b22c9551..6a9ef4d12 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/api/HyperskillConnector.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/api/HyperskillConnector.kt @@ -9,7 +9,6 @@ import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.project.Project import okhttp3.* -import okio.Buffer import org.apache.http.client.utils.URIBuilder import org.hyperskill.academy.learning.* import org.hyperskill.academy.learning.api.EduOAuthCodeFlowConnector @@ -82,14 +81,6 @@ abstract class HyperskillConnector : EduOAuthCodeFlowConnector): Result { - LOG.info("Submitting RemoteEduTask: ${task.name} (id=${task.id}), checkProfile='${task.checkProfile}'") + LOG.debug("Submitting RemoteEduTask `${task.name}` (id=${task.id})") if (task.checkProfile.isEmpty()) { - val message = "Check profile is empty for task ${task.name}. Submission aborted to avoid server error." + val message = EduCoreBundle.message( + "hyperskill.error.empty.check.profile", + task.name, + EduCoreBundle.message("hyperskill.action.synchronize.project") + ) LOG.error(message) - return Err(message + " Please try to 'Synchronize project' or contact support.") + return Err(message) } val connector = HyperskillConnector.getInstance() val attempt = connector.postAttempt(task).onError { @@ -95,4 +99,4 @@ object HyperskillSubmitConnector { } private val LOG: Logger = logger() -} \ No newline at end of file +} diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/submissions/HyperskillSubmissionFactory.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/submissions/HyperskillSubmissionFactory.kt index e681a3f65..ef81a9bea 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/submissions/HyperskillSubmissionFactory.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/submissions/HyperskillSubmissionFactory.kt @@ -23,7 +23,6 @@ object HyperskillSubmissionFactory { reply.score = if (task.status == CheckStatus.Solved) "1" else "0" reply.solution = files reply.feedback = Feedback(feedback) - reply.checkProfile = "" return StepikBasedSubmission(attempt, reply) } @@ -34,4 +33,4 @@ object HyperskillSubmissionFactory { reply.checkProfile = task.checkProfile return StepikBasedSubmission(attempt, reply) } -} \ No newline at end of file +} diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlFormatSynchronizer.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlFormatSynchronizer.kt index 3347708a5..0c02aa1b2 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlFormatSynchronizer.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlFormatSynchronizer.kt @@ -51,7 +51,6 @@ import javax.swing.JLabel import javax.swing.JPanel object YamlFormatSynchronizer { - private val LOG = com.intellij.openapi.diagnostic.logger() val LOAD_FROM_CONFIG = Key("Hyperskill.loadItem") val SAVE_TO_CONFIG = Key("Hyperskill.saveItem") @@ -180,7 +179,6 @@ object YamlFormatSynchronizer { ) } val yamlText = mapper.writeValueAsString(this) - LOG.info("Saving item ${this.name} to ${file.name}. YAML content snippet: ${yamlText.take(200)}") val formattedYamlText = reformatYaml(project, file.name, yamlText) VfsUtil.saveText(file, formattedYamlText) @@ -259,4 +257,4 @@ private fun Course.disambiguateAdditionalFilesContents(project: Project) { additionalFile.contents = disambiguatedContents } -} \ No newline at end of file +} diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt index 070f41295..14f41382c 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt @@ -8,13 +8,11 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import com.intellij.util.messages.Topic import org.hyperskill.academy.learning.* -import org.hyperskill.academy.learning.api.EduOAuthCodeFlowConnector import org.hyperskill.academy.learning.courseFormat.* import org.hyperskill.academy.learning.courseFormat.ext.customContentPath import org.hyperskill.academy.learning.courseFormat.ext.getDir import org.hyperskill.academy.learning.courseFormat.ext.getPathToChildren import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse -import org.hyperskill.academy.learning.courseFormat.tasks.RemoteEduTask import org.hyperskill.academy.learning.courseFormat.tasks.Task import org.hyperskill.academy.learning.courseFormat.tasks.UnsupportedTask import org.hyperskill.academy.learning.messages.EduCoreBundle @@ -29,9 +27,6 @@ import org.hyperskill.academy.learning.yaml.errorHandling.* import org.hyperskill.academy.learning.yaml.format.YamlMixinNames.TASK import org.hyperskill.academy.learning.yaml.format.getChangeApplierForItem import org.jetbrains.annotations.NonNls -import java.net.HttpURLConnection.HTTP_BAD_REQUEST -import java.net.HttpURLConnection.HTTP_FORBIDDEN -import java.net.HttpURLConnection.HTTP_UNAUTHORIZED /** * Get fully-initialized [StudyItem] object from yaml config file. @@ -67,9 +62,6 @@ object YamlLoader { val existingItem = getStudyItemForConfig(project, configFile) val deserializedItem = deserializeItemProcessingErrors(configFile, project, loadFromVFile, mapper) ?: return - if (deserializedItem is Task) { - com.intellij.openapi.diagnostic.logger().info("Deserialized task ${deserializedItem.name} (type=${deserializedItem.itemType}), checkProfile='${(deserializedItem as? RemoteEduTask)?.checkProfile}'") - } val customContentPath = existingItem?.course.customContentPath deserializedItem.ensureChildrenExist(configFile.parent, customContentPath) @@ -240,8 +232,8 @@ object YamlLoader { @PublishedApi internal fun Lesson.isHyperskillTopicsLesson(): Boolean { - val section = parentOrNull as? Section ?: return false - return course is HyperskillCourse && section.presentableName == EduFormatNames.HYPERSKILL_TOPICS + val course = course as? HyperskillCourse ?: return false + return (parentOrNull as? Section) === course.getTopicsSection() } private fun StudyItem.ensureChildrenExist(itemDir: VirtualFile, customContentPath: String) { diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt index 55328bc4f..bab9ea198 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt @@ -1,11 +1,9 @@ package org.hyperskill.academy.learning.yaml.format.student import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project import org.hyperskill.academy.learning.courseFormat.StudyItem import org.hyperskill.academy.learning.courseFormat.TaskFile -import org.hyperskill.academy.learning.courseFormat.tasks.EduTask import org.hyperskill.academy.learning.courseFormat.tasks.RemoteEduTask import org.hyperskill.academy.learning.courseFormat.tasks.Task import org.hyperskill.academy.learning.messages.EduCoreBundle @@ -13,11 +11,7 @@ import org.hyperskill.academy.learning.yaml.errorHandling.YamlLoadingException import org.hyperskill.academy.learning.yaml.format.TaskChangeApplier class StudentTaskChangeApplier(project: Project) : TaskChangeApplier(project) { - private val LOG = logger() override fun applyChanges(existingItem: Task, deserializedItem: Task) { - LOG.info("Applying changes for task: ${existingItem.name} (id=${existingItem.id})") - LOG.info("Existing item checkProfile: ${(existingItem as? RemoteEduTask)?.checkProfile}") - LOG.info("Deserialized item checkProfile: ${(deserializedItem as? RemoteEduTask)?.checkProfile}") if (existingItem.solutionHidden != deserializedItem.solutionHidden && !ApplicationManager.getApplication().isInternal) { throw YamlLoadingException(EduCoreBundle.message("yaml.editor.invalid.visibility.cannot.be.changed")) } @@ -30,13 +24,9 @@ class StudentTaskChangeApplier(project: Project) : TaskChangeApplier(project) { if (existingItem is RemoteEduTask && deserializedItem is RemoteEduTask) { val newCheckProfile = deserializedItem.checkProfile - if (newCheckProfile != existingItem.checkProfile) { - LOG.info("Updating checkProfile for task ${existingItem.name}: '${existingItem.checkProfile}' -> '$newCheckProfile'") + if (newCheckProfile.isNotEmpty() && newCheckProfile != existingItem.checkProfile) { existingItem.checkProfile = newCheckProfile } - if (existingItem.checkProfile.isEmpty()) { - LOG.warn("checkProfile is empty for RemoteEduTask ${existingItem.name} after applying changes") - } } } @@ -48,4 +38,4 @@ class StudentTaskChangeApplier(project: Project) : TaskChangeApplier(project) { override fun changeType(project: Project, existingItem: StudyItem, deserializedItem: Task) { throw YamlLoadingException(EduCoreBundle.message("yaml.editor.invalid.not.allowed.to.change.task")) } -} \ No newline at end of file +} diff --git a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/format/yaml/StudentChangeApplierTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/format/yaml/StudentChangeApplierTest.kt index c9c40bc9e..0721a44e6 100644 --- a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/format/yaml/StudentChangeApplierTest.kt +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/format/yaml/StudentChangeApplierTest.kt @@ -2,6 +2,7 @@ package org.hyperskill.academy.learning.format.yaml import org.hyperskill.academy.learning.courseFormat.TaskFile import org.hyperskill.academy.learning.courseFormat.tasks.EduTask +import org.hyperskill.academy.learning.courseFormat.tasks.RemoteEduTask import org.hyperskill.academy.learning.yaml.YamlTestCase import org.hyperskill.academy.learning.yaml.format.getChangeApplierForItem import org.junit.Test @@ -60,4 +61,38 @@ class StudentChangeApplierTest : YamlTestCase() { assertEquals(deserializedItem.taskFiles.values.first().text, existingItem.taskFiles.values.first().text) } -} \ No newline at end of file + + @Test + fun `test remote edu task keeps existing check profile when deserialized value is empty`() { + val existingItem = courseWithFiles { + lesson { + remoteEduTask("task1", checkProfile = "hyperskill_go") + } + }.lessons.first().taskList.first() as RemoteEduTask + val deserializedItem = RemoteEduTask().apply { + name = "task1" + checkProfile = "" + } + + getChangeApplierForItem(project, existingItem).applyChanges(existingItem, deserializedItem) + + assertEquals("hyperskill_go", existingItem.checkProfile) + } + + @Test + fun `test remote edu task updates check profile when deserialized value is non empty`() { + val existingItem = courseWithFiles { + lesson { + remoteEduTask("task1", checkProfile = "hyperskill_go") + } + }.lessons.first().taskList.first() as RemoteEduTask + val deserializedItem = RemoteEduTask().apply { + name = "task1" + checkProfile = "hyperskill_kotlin" + } + + getChangeApplierForItem(project, existingItem).applyChanges(existingItem, deserializedItem) + + assertEquals("hyperskill_kotlin", existingItem.checkProfile) + } +} diff --git a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/stepik/hyperskill/HyperskillCreateSubmissionTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/stepik/hyperskill/HyperskillCreateSubmissionTest.kt index 85962b1d9..546b60388 100644 --- a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/stepik/hyperskill/HyperskillCreateSubmissionTest.kt +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/stepik/hyperskill/HyperskillCreateSubmissionTest.kt @@ -56,7 +56,6 @@ class HyperskillCreateSubmissionTest : EduTestCase() { | is_visible: true | - name: src/Test.kt | is_visible: false - | check_profile: "" | """.trimMargin() ) @@ -83,7 +82,6 @@ class HyperskillCreateSubmissionTest : EduTestCase() { | is_visible: true | - name: src/Test.kt | is_visible: false - | check_profile: "" | """.trimMargin() )