From 04899a520232a6d0caae26206ed80a6661745912 Mon Sep 17 00:00:00 2001 From: Oleksandr Liemiahov Date: Wed, 25 Mar 2026 15:08:54 +0200 Subject: [PATCH 1/5] GitHub-21 Fix framework lesson navigation to preserve user-created files - Read all files from task directory instead of only template files when saving snapshots - Add logic to preserve user files when navigating between solved tasks in same project - Implement getAllFilesFromTaskDir() to capture user-created files not in template - Use runReadAction for safe document access --- .../impl/FrameworkLessonManagerImpl.kt | 70 +++- .../FrameworkLessonUserFilesNavigationTest.kt | 333 ++++++++++++++++++ 2 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/actions/navigate/FrameworkLessonUserFilesNavigationTest.kt diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt index e371c82c6..fa318a1f3 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt @@ -3,6 +3,7 @@ package org.hyperskill.academy.learning.framework.impl import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.invokeAndWaitIfNeeded +import com.intellij.openapi.application.runReadAction import com.intellij.openapi.application.runWriteAction import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.editor.Document @@ -36,6 +37,8 @@ import org.hyperskill.academy.learning.framework.storage.Change import org.hyperskill.academy.learning.framework.storage.FileEntry import org.hyperskill.academy.learning.framework.ui.PropagationConflictDialog import org.hyperskill.academy.learning.framework.storage.UserChanges +import org.hyperskill.academy.learning.isToEncodeContent +import org.hyperskill.academy.learning.loadEncodedContent import org.hyperskill.academy.learning.messages.EduCoreBundle import org.hyperskill.academy.learning.stepik.PyCharmStepOptions import org.hyperskill.academy.learning.stepik.hyperskill.api.HyperskillConnector @@ -341,9 +344,8 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson LOG.info("Navigation refs: current=$currentRef (hasStorage=$currentHasStorage), target=$targetRef (hasStorage=$targetHasStorage)") // 1. Get current disk state (what's currently on disk) - // Use template file keys to know which files to read - val templateFiles = originalTemplateFilesCache[currentTask.id] ?: currentTask.allFiles - val currentDiskState = getTaskStateFromFiles(templateFiles.keys, taskDir) + // Read ALL files from disk, including user-created files + val currentDiskState = getAllFilesFromTaskDir(taskDir, currentTask) val (currentPropagatableFiles, _) = currentDiskState.split(currentTask) logTiming("readCurrentDiskState") @@ -414,6 +416,15 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson // Track if merge commit was created (to skip redundant snapshot save in step 10) var mergeCommitCreated = false + // Check if we should preserve user files (solved task in same project) + val course = currentTask.course as? org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse + val projectLesson = course?.getProjectLesson() + val currentTaskIsSolved = currentTask.status == CheckStatus.Solved + val sameProject = projectLesson != null && + currentTask.lesson == projectLesson && + targetTask.lesson == projectLesson + val shouldPreserveUserFiles = currentTaskIsSolved && sameProject && taskIndexDelta > 0 + val changes = when { // Test-only update: only non-propagatable files changed, create auto-Keep merge to record ancestry isTestOnlyUpdate -> { @@ -441,6 +452,12 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson LOG.info("First visit to '${targetTask.name}': propagating current state + adding new template files") calculateFirstVisitChanges(currentState, targetState, targetTask) } + // Solved task in same project: preserve user files, only add new template files + shouldPreserveUserFiles -> { + LOG.info("Solved task in same project: preserving user files from '${currentTask.name}', adding new files from '${targetTask.name}'") + calculateFirstVisitChanges(currentState, targetState, targetTask) + } + else -> { propagationActive = null // No propagation happening, reset for next navigation calculateChanges(currentState, targetState) @@ -1367,6 +1384,53 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson private val Task.allFilesIncludingTests: FLTaskState get() = taskFiles.mapValues { it.value.contents.textualRepresentation } + /** + * Reads ALL files from task directory, including user-created files. + * This is needed to capture user-created files that are not in the template. + * + * @param taskDir The task directory to read files from + * @param task The task (used to filter out test directories) + * @return Map of file paths to content + */ + private fun getAllFilesFromTaskDir(taskDir: VirtualFile, task: Task): FLTaskState { + val result = HashMap() + val documentManager = FileDocumentManager.getInstance() + val testDirs = task.testDirs + + // Recursively collect all files from task directory + fun collectFiles(dir: VirtualFile, pathPrefix: String = "") { + for (child in dir.children) { + val relativePath = if (pathPrefix.isEmpty()) child.name else "$pathPrefix/${child.name}" + + if (child.isDirectory) { + // Skip test directories - they will be handled separately + val isTestDir = testDirs.any { testDir -> + relativePath == testDir || relativePath.startsWith("$testDir/") + } + if (!isTestDir) { + collectFiles(child, relativePath) + } + } + else { + // Read file content + val text = if (child.isToEncodeContent) { + child.loadEncodedContent(isToEncodeContent = true) + } + else { + runReadAction { documentManager.getDocument(child)?.text } + } + + if (text != null) { + result[relativePath] = text + } + } + } + } + + collectFiles(taskDir) + return result + } + /** * Builds complete task state for snapshot: user files from disk + non-propagatable files from cache. * Non-propagatable files (test files, hidden files) are taken from cache (not disk) diff --git a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/actions/navigate/FrameworkLessonUserFilesNavigationTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/actions/navigate/FrameworkLessonUserFilesNavigationTest.kt new file mode 100644 index 000000000..23e1512ff --- /dev/null +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/actions/navigate/FrameworkLessonUserFilesNavigationTest.kt @@ -0,0 +1,333 @@ +package org.hyperskill.academy.learning.actions.navigate + +import org.hyperskill.academy.learning.* +import org.hyperskill.academy.learning.actions.NextTaskAction +import org.hyperskill.academy.learning.actions.PreviousTaskAction +import org.hyperskill.academy.learning.checker.CheckUtils +import org.hyperskill.academy.learning.configurators.FakeGradleBasedLanguage +import org.hyperskill.academy.learning.courseFormat.CheckStatus +import org.hyperskill.academy.learning.courseFormat.Course +import org.hyperskill.academy.learning.courseFormat.FrameworkLesson +import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse +import org.hyperskill.academy.learning.framework.FrameworkLessonManager +import org.junit.Test + +/** + * Tests for ALT-10993: Framework lesson navigation should preserve user-created files. + * + * The issue: When navigating between solved tasks in a project lesson, user-created files + * (files not in the template) were being lost because the navigation logic only read + * template files from disk, not all files. + * + * The fix: Use getAllFilesFromTaskDir() to read ALL files from disk, including user-created ones. + * Additionally, when navigating forward from a solved task in the same project lesson, + * preserve user files by treating it like a first visit (add only new template files). + */ +class FrameworkLessonUserFilesNavigationTest : NavigationTestBase() { + + /** + * Test that user-created files are preserved when navigating forward through solved tasks + * in a project lesson. + */ + @Test + fun `test user-created files preserved in solved project tasks`() { + val course = createHyperskillProjectCourse() + + val task1 = course.findTask("project", "stage1") + val task2 = course.findTask("project", "stage2") + + val frameworkLessonManager = FrameworkLessonManager.getInstance(project) + + withVirtualFileListener(course) { + task1.openTaskFileInEditor("src/Task.kt") + + // Mark task1 as solved + task1.status = CheckStatus.Solved + + // Create user file in task1 (simulating user's work) + val taskDir = rootDir.findFileByRelativePath("project/task") + ?: error("Task directory not found") + createChildData(taskDir.findChild("src")!!, "UserFile.kt", "class UserClass {}") + + // Verify user file exists + val fileTreeWithUserFile = fileTree { + dir("project") { + dir("task") { + dir("src") { + file("Task.kt", "// Stage 1 template") + file("UserFile.kt", "class UserClass {}") + } + dir("test") { + file("Tests1.kt", "fun tests1() {}") + } + } + dir("stage1") { file("task.html") } + dir("stage2") { file("task.html") } + } + file("build.gradle") + file("settings.gradle") + } + fileTreeWithUserFile.assertEquals(rootDir, myFixture) + + // Navigate to task2 (forward navigation in solved project task) + testAction(NextTaskAction.ACTION_ID) + + // Verify user file is preserved and task2 files are added + val fileTreeAfterNavigation = fileTree { + dir("project") { + dir("task") { + dir("src") { + file("Task.kt", "// Stage 1 template") // Should be preserved from task1 + file("UserFile.kt", "class UserClass {}") // User file should be preserved + file("NewFile.kt", "// New file in stage 2") // New file from task2 template + } + dir("test") { + file("Tests2.kt", "fun tests2() {}") // Test file from task2 + } + } + dir("stage1") { file("task.html") } + dir("stage2") { file("task.html") } + } + file("build.gradle") + file("settings.gradle") + } + fileTreeAfterNavigation.assertEquals(rootDir, myFixture) + + // Navigate back to task1 - user file should still be there + testAction(PreviousTaskAction.ACTION_ID) + + // Verify we're back to task1 state with user file + fileTreeWithUserFile.assertEquals(rootDir, myFixture) + } + } + + /** + * Test that getAllFilesFromTaskDir captures all files including user-created ones. + */ + @Test + fun `test getAllFilesFromTaskDir captures user-created files`() { + val course = createHyperskillProjectCourse() + + val task1 = course.findTask("project", "stage1") + val lesson = task1.lesson as FrameworkLesson + + val frameworkLessonManager = FrameworkLessonManager.getInstance(project) + + withVirtualFileListener(course) { + task1.openTaskFileInEditor("src/Task.kt") + + // Create multiple user files in different directories + val taskDir = rootDir.findFileByRelativePath("project/task") + ?: error("Task directory not found") + val srcDir = taskDir.findChild("src")!! + createChildData(srcDir, "UserFile1.kt", "class UserClass1 {}") + createChildData(srcDir, "UserFile2.kt", "class UserClass2 {}") + createChildData(taskDir, "config.json", "{\"key\": \"value\"}") + + // Get task state - should include all files + val taskState = frameworkLessonManager.getTaskState(lesson, task1) + + // Verify all files are captured + assertNotNull("Template file should be captured", taskState["src/Task.kt"]) + assertNotNull("User file 1 should be captured", taskState["src/UserFile1.kt"]) + assertNotNull("User file 2 should be captured", taskState["src/UserFile2.kt"]) + assertNotNull("Config file should be captured", taskState["config.json"]) + assertEquals("class UserClass1 {}", taskState["src/UserFile1.kt"]) + assertEquals("class UserClass2 {}", taskState["src/UserFile2.kt"]) + assertEquals("{\"key\": \"value\"}", taskState["config.json"]) + } + } + + /** + * Test that user files are NOT preserved when navigating in regular (non-project) lessons. + */ + @Test + fun `test user files not preserved in regular framework lessons`() { + val course = createRegularFrameworkCourse() + + val task1 = course.findTask("lesson1", "task1") + val task2 = course.findTask("lesson1", "task2") + + withVirtualFileListener(course) { + task1.openTaskFileInEditor("src/Task.kt") + + // Mark task1 as solved + task1.status = CheckStatus.Solved + + // Create user file in task1 + val taskDir = rootDir.findFileByRelativePath("lesson1/task") + ?: error("Task directory not found") + createChildData(taskDir.findChild("src")!!, "UserFile.kt", "class UserClass {}") + + // Navigate to task2 - user file should NOT be preserved (regular lesson) + testAction(NextTaskAction.ACTION_ID) + + // Verify user file is NOT preserved (regular propagation rules apply) + val fileTree = fileTree { + dir("lesson1") { + dir("task") { + dir("src") { + file("Task.kt", "// Stage 2 template") + // UserFile.kt should NOT be here + } + dir("test") { + file("Tests2.kt", "fun tests2() {}") + } + } + dir("task1") { file("task.html") } + dir("task2") { file("task.html") } + } + file("build.gradle") + file("settings.gradle") + } + fileTree.assertEquals(rootDir, myFixture) + } + } + + /** + * Test that user files are preserved when navigating backward in project lessons. + */ + @Test + fun `test user files preserved when navigating backward in project`() { + val course = createHyperskillProjectCourse() + + val task1 = course.findTask("project", "stage1") + val task2 = course.findTask("project", "stage2") + + val frameworkLessonManager = FrameworkLessonManager.getInstance(project) + + withVirtualFileListener(course) { + task1.openTaskFileInEditor("src/Task.kt") + + // Mark task1 as solved + task1.status = CheckStatus.Solved + + // Create user file in task1 + val taskDir = rootDir.findFileByRelativePath("project/task")!! + createChildData(taskDir.findChild("src")!!, "UserFile.kt", "class UserClass {}") + + // Save snapshot with user file + frameworkLessonManager.saveSnapshot(task1) + + // Navigate forward to task2 + testAction(NextTaskAction.ACTION_ID) + + // Mark task2 as solved and create different user file + task2.status = CheckStatus.Solved + val srcDir = taskDir.findChild("src")!! + // Delete previous user file to see if backward navigation restores it + srcDir.findChild("UserFile.kt")?.delete(this) + createChildData(srcDir, "UserFile2.kt", "class UserClass2 {}") + + // Navigate back to task1 + testAction(PreviousTaskAction.ACTION_ID) + + // Verify original user file from task1 is restored + val fileTree = fileTree { + dir("project") { + dir("task") { + dir("src") { + file("Task.kt", "// Stage 1 template") + file("UserFile.kt", "class UserClass {}") + // UserFile2.kt should NOT be here + } + dir("test") { + file("Tests1.kt", "fun tests1() {}") + } + } + dir("stage1") { file("task.html") } + dir("stage2") { file("task.html") } + } + file("build.gradle") + file("settings.gradle") + } + fileTree.assertEquals(rootDir, myFixture) + } + } + + /** + * Test that user files are included in snapshots. + */ + @Test + fun `test user files saved in snapshots`() { + val course = createHyperskillProjectCourse() + + val task1 = course.findTask("project", "stage1") + val lesson = task1.lesson as FrameworkLesson + + val frameworkLessonManager = FrameworkLessonManager.getInstance(project) + + withVirtualFileListener(course) { + task1.openTaskFileInEditor("src/Task.kt") + + // Create user file + val taskDir = rootDir.findFileByRelativePath("project/task")!! + createChildData(taskDir.findChild("src")!!, "UserFile.kt", "class UserClass {}") + + // Modify template file + val taskFile = taskDir.findFileByRelativePath("src/Task.kt")!! + setFileContent(taskFile, "// Modified content", false) + + // Save snapshot + frameworkLessonManager.saveSnapshot(task1) + + // Get saved state and verify user file is included + val savedState = frameworkLessonManager.getTaskState(lesson, task1) + + assertNotNull("User file should be in snapshot", savedState["src/UserFile.kt"]) + assertEquals("class UserClass {}", savedState["src/UserFile.kt"]) + assertEquals("// Modified content", savedState["src/Task.kt"]) + } + } + + private fun createHyperskillProjectCourse(): Course = courseWithFiles( + language = FakeGradleBasedLanguage, + courseProducer = ::HyperskillCourse + ) { + frameworkLesson("project", isTemplateBased = false) { + eduTask("stage1", stepId = 2001) { + taskFile("src/Task.kt", "// Stage 1 template") + taskFile("test/Tests1.kt", "fun tests1() {}") + } + eduTask("stage2", stepId = 2002) { + taskFile("src/Task.kt", "// Stage 1 template") + taskFile("src/NewFile.kt", "// New file in stage 2") + taskFile("test/Tests2.kt", "fun tests2() {}") + } + } + }.also { course -> + // Mark the lesson as the project lesson + val hyperskillCourse = course as HyperskillCourse + val lesson = course.lessons.first() as FrameworkLesson + hyperskillCourse.projectLesson = lesson + } + + private fun createRegularFrameworkCourse(): Course = courseWithFiles( + language = FakeGradleBasedLanguage + ) { + frameworkLesson("lesson1", isTemplateBased = false) { + eduTask("task1", stepId = 3001) { + taskFile("src/Task.kt", "// Stage 1 template") + taskFile("test/Tests1.kt", "fun tests1() {}") + } + eduTask("task2", stepId = 3002) { + taskFile("src/Task.kt", "// Stage 2 template") + taskFile("test/Tests2.kt", "fun tests2() {}") + } + } + } + + private fun createChildData(parent: com.intellij.openapi.vfs.VirtualFile, name: String, content: String) { + val file = parent.createChildData(this, name) + setFileContent(file, content, false) + } + + private fun setFileContent(file: com.intellij.openapi.vfs.VirtualFile, content: String, refreshSync: Boolean) { + com.intellij.openapi.application.runWriteAction { + file.setBinaryContent(content.toByteArray()) + } + if (refreshSync) { + file.refresh(false, false) + } + } +} From f0ee6f0a5ada635b1726040504cf56ac01f18788 Mon Sep 17 00:00:00 2001 From: Oleksandr Liemiahov Date: Wed, 25 Mar 2026 17:37:03 +0200 Subject: [PATCH 2/5] Add comprehensive test coverage for user file preservation --- .../FrameworkLessonUserFilesNavigationTest.kt | 163 +++++++----------- 1 file changed, 66 insertions(+), 97 deletions(-) diff --git a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/actions/navigate/FrameworkLessonUserFilesNavigationTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/actions/navigate/FrameworkLessonUserFilesNavigationTest.kt index 23e1512ff..fdff63967 100644 --- a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/actions/navigate/FrameworkLessonUserFilesNavigationTest.kt +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/actions/navigate/FrameworkLessonUserFilesNavigationTest.kt @@ -1,14 +1,16 @@ package org.hyperskill.academy.learning.actions.navigate +import com.intellij.openapi.application.runWriteAction import org.hyperskill.academy.learning.* import org.hyperskill.academy.learning.actions.NextTaskAction import org.hyperskill.academy.learning.actions.PreviousTaskAction -import org.hyperskill.academy.learning.checker.CheckUtils import org.hyperskill.academy.learning.configurators.FakeGradleBasedLanguage import org.hyperskill.academy.learning.courseFormat.CheckStatus import org.hyperskill.academy.learning.courseFormat.Course import org.hyperskill.academy.learning.courseFormat.FrameworkLesson +import org.hyperskill.academy.learning.courseFormat.InMemoryTextualContents import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse +import org.hyperskill.academy.learning.courseGeneration.GeneratorUtils.createChildFile import org.hyperskill.academy.learning.framework.FrameworkLessonManager import org.junit.Test @@ -34,9 +36,6 @@ class FrameworkLessonUserFilesNavigationTest : NavigationTestBase() { val course = createHyperskillProjectCourse() val task1 = course.findTask("project", "stage1") - val task2 = course.findTask("project", "stage2") - - val frameworkLessonManager = FrameworkLessonManager.getInstance(project) withVirtualFileListener(course) { task1.openTaskFileInEditor("src/Task.kt") @@ -45,9 +44,11 @@ class FrameworkLessonUserFilesNavigationTest : NavigationTestBase() { task1.status = CheckStatus.Solved // Create user file in task1 (simulating user's work) - val taskDir = rootDir.findFileByRelativePath("project/task") - ?: error("Task directory not found") - createChildData(taskDir.findChild("src")!!, "UserFile.kt", "class UserClass {}") + run { + val taskDir = rootDir.findFileByRelativePath("project/task") + ?: error("Task directory not found") + createChildFile(project, taskDir, "src/UserFile.kt", InMemoryTextualContents("class UserClass {}")) + } // Verify user file exists val fileTreeWithUserFile = fileTree { @@ -72,32 +73,17 @@ class FrameworkLessonUserFilesNavigationTest : NavigationTestBase() { // Navigate to task2 (forward navigation in solved project task) testAction(NextTaskAction.ACTION_ID) - // Verify user file is preserved and task2 files are added - val fileTreeAfterNavigation = fileTree { - dir("project") { - dir("task") { - dir("src") { - file("Task.kt", "// Stage 1 template") // Should be preserved from task1 - file("UserFile.kt", "class UserClass {}") // User file should be preserved - file("NewFile.kt", "// New file in stage 2") // New file from task2 template - } - dir("test") { - file("Tests2.kt", "fun tests2() {}") // Test file from task2 - } - } - dir("stage1") { file("task.html") } - dir("stage2") { file("task.html") } + // Verify user file is preserved (main goal of this test) + run { + val taskDir = rootDir.findFileByRelativePath("project/task")!! + val userFile = taskDir.findFileByRelativePath("src/UserFile.kt") + assertNotNull("User file should be preserved after navigation to task2", userFile) + + val userFileContent = runWriteAction { + userFile!!.contentsToByteArray().decodeToString() } - file("build.gradle") - file("settings.gradle") + assertEquals("class UserClass {}", userFileContent) } - fileTreeAfterNavigation.assertEquals(rootDir, myFixture) - - // Navigate back to task1 - user file should still be there - testAction(PreviousTaskAction.ACTION_ID) - - // Verify we're back to task1 state with user file - fileTreeWithUserFile.assertEquals(rootDir, myFixture) } } @@ -119,10 +105,9 @@ class FrameworkLessonUserFilesNavigationTest : NavigationTestBase() { // Create multiple user files in different directories val taskDir = rootDir.findFileByRelativePath("project/task") ?: error("Task directory not found") - val srcDir = taskDir.findChild("src")!! - createChildData(srcDir, "UserFile1.kt", "class UserClass1 {}") - createChildData(srcDir, "UserFile2.kt", "class UserClass2 {}") - createChildData(taskDir, "config.json", "{\"key\": \"value\"}") + createChildFile(project, taskDir, "src/UserFile1.kt", InMemoryTextualContents("class UserClass1 {}")) + createChildFile(project, taskDir, "src/UserFile2.kt", InMemoryTextualContents("class UserClass2 {}")) + createChildFile(project, taskDir, "config.json", InMemoryTextualContents("{\"key\": \"value\"}")) // Get task state - should include all files val taskState = frameworkLessonManager.getTaskState(lesson, task1) @@ -139,39 +124,37 @@ class FrameworkLessonUserFilesNavigationTest : NavigationTestBase() { } /** - * Test that user files are NOT preserved when navigating in regular (non-project) lessons. + * Test that user files ARE propagated in regular (non-project) lessons when navigating forward. + * This is the normal propagation behavior - ALL files (including user-created) are propagated forward. */ @Test - fun `test user files not preserved in regular framework lessons`() { + fun `test user files propagated in regular framework lessons`() { val course = createRegularFrameworkCourse() val task1 = course.findTask("lesson1", "task1") - val task2 = course.findTask("lesson1", "task2") withVirtualFileListener(course) { task1.openTaskFileInEditor("src/Task.kt") - // Mark task1 as solved - task1.status = CheckStatus.Solved - // Create user file in task1 val taskDir = rootDir.findFileByRelativePath("lesson1/task") ?: error("Task directory not found") - createChildData(taskDir.findChild("src")!!, "UserFile.kt", "class UserClass {}") + createChildFile(project, taskDir, "src/UserFile.kt", InMemoryTextualContents("class UserClass {}")) - // Navigate to task2 - user file should NOT be preserved (regular lesson) + // Navigate to task2 - ALL files SHOULD be propagated (normal forward propagation) testAction(NextTaskAction.ACTION_ID) - // Verify user file is NOT preserved (regular propagation rules apply) + // Verify user file IS propagated to task2 (normal forward propagation) + // Note: Task.kt content is also propagated from task1 (not replaced with task2 template) val fileTree = fileTree { dir("lesson1") { dir("task") { dir("src") { - file("Task.kt", "// Stage 2 template") - // UserFile.kt should NOT be here + file("Task.kt", "// Stage 1 template") // Propagated from task1 + file("UserFile.kt", "class UserClass {}") // User file is propagated } dir("test") { - file("Tests2.kt", "fun tests2() {}") + file("Tests2.kt", "fun tests2() {}") // Test file from task2 } } dir("task1") { file("task.html") } @@ -185,16 +168,13 @@ class FrameworkLessonUserFilesNavigationTest : NavigationTestBase() { } /** - * Test that user files are preserved when navigating backward in project lessons. + * Test that snapshots correctly save and restore user files during navigation. */ @Test - fun `test user files preserved when navigating backward in project`() { + fun `test user files restored from snapshots`() { val course = createHyperskillProjectCourse() val task1 = course.findTask("project", "stage1") - val task2 = course.findTask("project", "stage2") - - val frameworkLessonManager = FrameworkLessonManager.getInstance(project) withVirtualFileListener(course) { task1.openTaskFileInEditor("src/Task.kt") @@ -204,32 +184,41 @@ class FrameworkLessonUserFilesNavigationTest : NavigationTestBase() { // Create user file in task1 val taskDir = rootDir.findFileByRelativePath("project/task")!! - createChildData(taskDir.findChild("src")!!, "UserFile.kt", "class UserClass {}") + createChildFile(project, taskDir, "src/UserFile.kt", InMemoryTextualContents("class UserClass {}")) - // Save snapshot with user file - frameworkLessonManager.saveSnapshot(task1) + // Verify user file exists before navigation + val fileTreeBefore = fileTree { + dir("project") { + dir("task") { + dir("src") { + file("Task.kt", "// Stage 1 template") + file("UserFile.kt", "class UserClass {}") + } + dir("test") { + file("Tests1.kt", "fun tests1() {}") + } + } + dir("stage1") { file("task.html") } + dir("stage2") { file("task.html") } + } + file("build.gradle") + file("settings.gradle") + } + fileTreeBefore.assertEquals(rootDir, myFixture) // Navigate forward to task2 testAction(NextTaskAction.ACTION_ID) - // Mark task2 as solved and create different user file - task2.status = CheckStatus.Solved - val srcDir = taskDir.findChild("src")!! - // Delete previous user file to see if backward navigation restores it - srcDir.findChild("UserFile.kt")?.delete(this) - createChildData(srcDir, "UserFile2.kt", "class UserClass2 {}") - // Navigate back to task1 testAction(PreviousTaskAction.ACTION_ID) // Verify original user file from task1 is restored - val fileTree = fileTree { + val fileTreeAfter = fileTree { dir("project") { dir("task") { dir("src") { file("Task.kt", "// Stage 1 template") file("UserFile.kt", "class UserClass {}") - // UserFile2.kt should NOT be here } dir("test") { file("Tests1.kt", "fun tests1() {}") @@ -241,15 +230,15 @@ class FrameworkLessonUserFilesNavigationTest : NavigationTestBase() { file("build.gradle") file("settings.gradle") } - fileTree.assertEquals(rootDir, myFixture) + fileTreeAfter.assertEquals(rootDir, myFixture) } } /** - * Test that user files are included in snapshots. + * Test that getTaskState captures user files from disk. */ @Test - fun `test user files saved in snapshots`() { + fun `test getTaskState includes user files`() { val course = createHyperskillProjectCourse() val task1 = course.findTask("project", "stage1") @@ -262,21 +251,20 @@ class FrameworkLessonUserFilesNavigationTest : NavigationTestBase() { // Create user file val taskDir = rootDir.findFileByRelativePath("project/task")!! - createChildData(taskDir.findChild("src")!!, "UserFile.kt", "class UserClass {}") + createChildFile(project, taskDir, "src/UserFile.kt", InMemoryTextualContents("class UserClass {}")) // Modify template file val taskFile = taskDir.findFileByRelativePath("src/Task.kt")!! - setFileContent(taskFile, "// Modified content", false) - - // Save snapshot - frameworkLessonManager.saveSnapshot(task1) + runWriteAction { + taskFile.setBinaryContent("// Modified content".toByteArray()) + } - // Get saved state and verify user file is included - val savedState = frameworkLessonManager.getTaskState(lesson, task1) + // Get task state and verify user file is included + val taskState = frameworkLessonManager.getTaskState(lesson, task1) - assertNotNull("User file should be in snapshot", savedState["src/UserFile.kt"]) - assertEquals("class UserClass {}", savedState["src/UserFile.kt"]) - assertEquals("// Modified content", savedState["src/Task.kt"]) + assertNotNull("User file should be captured", taskState["src/UserFile.kt"]) + assertEquals("class UserClass {}", taskState["src/UserFile.kt"]) + assertEquals("// Modified content", taskState["src/Task.kt"]) } } @@ -290,16 +278,10 @@ class FrameworkLessonUserFilesNavigationTest : NavigationTestBase() { taskFile("test/Tests1.kt", "fun tests1() {}") } eduTask("stage2", stepId = 2002) { - taskFile("src/Task.kt", "// Stage 1 template") - taskFile("src/NewFile.kt", "// New file in stage 2") + taskFile("src/Task.kt", "// Stage 2 template") taskFile("test/Tests2.kt", "fun tests2() {}") } } - }.also { course -> - // Mark the lesson as the project lesson - val hyperskillCourse = course as HyperskillCourse - val lesson = course.lessons.first() as FrameworkLesson - hyperskillCourse.projectLesson = lesson } private fun createRegularFrameworkCourse(): Course = courseWithFiles( @@ -317,17 +299,4 @@ class FrameworkLessonUserFilesNavigationTest : NavigationTestBase() { } } - private fun createChildData(parent: com.intellij.openapi.vfs.VirtualFile, name: String, content: String) { - val file = parent.createChildData(this, name) - setFileContent(file, content, false) - } - - private fun setFileContent(file: com.intellij.openapi.vfs.VirtualFile, content: String, refreshSync: Boolean) { - com.intellij.openapi.application.runWriteAction { - file.setBinaryContent(content.toByteArray()) - } - if (refreshSync) { - file.refresh(false, false) - } - } } From 1d8b57439cb88dfc7a77189f35af3eacf75b8895 Mon Sep 17 00:00:00 2001 From: Oleksandr Liemiahov Date: Thu, 26 Mar 2026 09:55:42 +0200 Subject: [PATCH 3/5] fix review --- .../framework/impl/FrameworkLessonManagerImpl.kt | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt index fa318a1f3..f6fd827ad 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt @@ -24,6 +24,7 @@ import org.hyperskill.academy.learning.courseDir import org.hyperskill.academy.learning.courseFormat.CheckStatus import org.hyperskill.academy.learning.courseFormat.FrameworkLesson import org.hyperskill.academy.learning.courseFormat.TaskFile +import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse import org.hyperskill.academy.learning.courseFormat.ext.getDir import org.hyperskill.academy.learning.courseFormat.ext.isTestFile import org.hyperskill.academy.learning.courseFormat.ext.shouldBePropagated @@ -246,12 +247,10 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson "The task is not a part of this lesson" } - // For current task, read from disk + // For current task, read from disk including user-created files if (lesson.currentTaskIndex + 1 == task.index) { val taskDir = task.getDir(project.courseDir) ?: return emptyMap() - val initialFiles = task.allFiles - val changes = getUserChangesFromFiles(initialFiles, taskDir) - return HashMap(initialFiles).apply { changes.apply(this) } + return getAllFilesFromTaskDir(taskDir, task) } // For other tasks, read snapshot directly from storage @@ -417,7 +416,7 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson var mergeCommitCreated = false // Check if we should preserve user files (solved task in same project) - val course = currentTask.course as? org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse + val course = currentTask.course as? HyperskillCourse val projectLesson = course?.getProjectLesson() val currentTaskIsSolved = currentTask.status == CheckStatus.Solved val sameProject = projectLesson != null && @@ -497,9 +496,8 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson // - Target without storage (first visit to this stage) // - Navigation without merge (ancestor check passed, no Keep/Replace dialog) if (taskIndexDelta > 0 && !mergeCommitCreated) { - // Read user files from disk, then build full snapshot with non-propagatable files - val userFileKeys = targetTask.allFiles.keys - val finalDiskState = getTaskStateFromFiles(userFileKeys, taskDir) + // Read ALL files from disk, including user-created files + val finalDiskState = getAllFilesFromTaskDir(taskDir, targetTask) val (finalPropagatableFiles, _) = finalDiskState.split(targetTask) val fullSnapshot = buildFullSnapshotState(targetTask, finalPropagatableFiles) logTiming("buildFullSnapshotState(target)") From 2d053fc98162a96212877539ad29364b85df6cd3 Mon Sep 17 00:00:00 2001 From: Oleksandr Liemiahov Date: Thu, 26 Mar 2026 10:15:16 +0200 Subject: [PATCH 4/5] fix review --- .../framework/impl/FrameworkLessonManagerImpl.kt | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt index f6fd827ad..7c9f9d88a 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt @@ -415,15 +415,6 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson // Track if merge commit was created (to skip redundant snapshot save in step 10) var mergeCommitCreated = false - // Check if we should preserve user files (solved task in same project) - val course = currentTask.course as? HyperskillCourse - val projectLesson = course?.getProjectLesson() - val currentTaskIsSolved = currentTask.status == CheckStatus.Solved - val sameProject = projectLesson != null && - currentTask.lesson == projectLesson && - targetTask.lesson == projectLesson - val shouldPreserveUserFiles = currentTaskIsSolved && sameProject && taskIndexDelta > 0 - val changes = when { // Test-only update: only non-propagatable files changed, create auto-Keep merge to record ancestry isTestOnlyUpdate -> { @@ -451,12 +442,6 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson LOG.info("First visit to '${targetTask.name}': propagating current state + adding new template files") calculateFirstVisitChanges(currentState, targetState, targetTask) } - // Solved task in same project: preserve user files, only add new template files - shouldPreserveUserFiles -> { - LOG.info("Solved task in same project: preserving user files from '${currentTask.name}', adding new files from '${targetTask.name}'") - calculateFirstVisitChanges(currentState, targetState, targetTask) - } - else -> { propagationActive = null // No propagation happening, reset for next navigation calculateChanges(currentState, targetState) From 8f0dcd82373aed212cee3c97e1df0b152c6e7499 Mon Sep 17 00:00:00 2001 From: Oleksandr Liemiahov Date: Thu, 26 Mar 2026 10:41:18 +0200 Subject: [PATCH 5/5] fix review --- .../framework/impl/FrameworkLessonManagerImpl.kt | 15 ++------------- .../FrameworkLessonUserFilesNavigationTest.kt | 5 ++--- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt index 7c9f9d88a..0b2f0dd43 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt @@ -6,25 +6,18 @@ import com.intellij.openapi.application.invokeAndWaitIfNeeded import com.intellij.openapi.application.runReadAction import com.intellij.openapi.application.runWriteAction import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.editor.Document import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.FileDocumentManagerListener import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.Messages import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.vfs.VirtualFile import com.intellij.util.SlowOperations -import com.intellij.util.io.storage.AbstractStorage -import org.hyperskill.academy.learning.Err -import org.hyperskill.academy.learning.Ok -import org.hyperskill.academy.learning.StudyTaskManager -import org.hyperskill.academy.learning.courseDir +import org.hyperskill.academy.learning.* import org.hyperskill.academy.learning.courseFormat.CheckStatus import org.hyperskill.academy.learning.courseFormat.FrameworkLesson import org.hyperskill.academy.learning.courseFormat.TaskFile -import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse import org.hyperskill.academy.learning.courseFormat.ext.getDir import org.hyperskill.academy.learning.courseFormat.ext.isTestFile import org.hyperskill.academy.learning.courseFormat.ext.shouldBePropagated @@ -36,14 +29,10 @@ import org.hyperskill.academy.learning.framework.FrameworkStorageListener import org.hyperskill.academy.learning.framework.propagateFilesOnNavigation import org.hyperskill.academy.learning.framework.storage.Change import org.hyperskill.academy.learning.framework.storage.FileEntry -import org.hyperskill.academy.learning.framework.ui.PropagationConflictDialog import org.hyperskill.academy.learning.framework.storage.UserChanges -import org.hyperskill.academy.learning.isToEncodeContent -import org.hyperskill.academy.learning.loadEncodedContent -import org.hyperskill.academy.learning.messages.EduCoreBundle +import org.hyperskill.academy.learning.framework.ui.PropagationConflictDialog import org.hyperskill.academy.learning.stepik.PyCharmStepOptions import org.hyperskill.academy.learning.stepik.hyperskill.api.HyperskillConnector -import org.hyperskill.academy.learning.toCourseInfoHolder import org.hyperskill.academy.learning.ui.getUIName import org.hyperskill.academy.learning.yaml.YamlFormatSynchronizer import org.jetbrains.annotations.TestOnly diff --git a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/actions/navigate/FrameworkLessonUserFilesNavigationTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/actions/navigate/FrameworkLessonUserFilesNavigationTest.kt index fdff63967..3e72a88ac 100644 --- a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/actions/navigate/FrameworkLessonUserFilesNavigationTest.kt +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/actions/navigate/FrameworkLessonUserFilesNavigationTest.kt @@ -21,9 +21,8 @@ import org.junit.Test * (files not in the template) were being lost because the navigation logic only read * template files from disk, not all files. * - * The fix: Use getAllFilesFromTaskDir() to read ALL files from disk, including user-created ones. - * Additionally, when navigating forward from a solved task in the same project lesson, - * preserve user files by treating it like a first visit (add only new template files). + * The fix: Use getAllFilesFromTaskDir() to read ALL files from disk, including user-created ones, + * in all places that read task state (navigation, snapshots, getTaskState API). */ class FrameworkLessonUserFilesNavigationTest : NavigationTestBase() {