Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,18 @@
"Bash(bd dep:*)",
"Bash(bd close:*)",
"Bash(bd sync:*)",
"Bash(bd stats:*)"
"Bash(bd stats:*)",
"Bash(git checkout:*)",
"Bash(git push:*)",
"Bash(gh pr create --title \"Fix missing Platform.runLater in event handlers\" --base main --body \"$\\(cat <<''EOF''\n## Summary\n- Wrapped all model updates in `Platform.runLater\\(\\)` to ensure UI updates happen on the JavaFX Application Thread\n- Fixed handlers: `handleDocumentTagAddedEvent`, `handleArchiveEntryAddedEvent`, `handleLightThemeActivatedSettingsChangedEvent`\n\n## Issue\nFixes smartfiles-vap\n\n## Test plan\n- [x] All 51 existing tests pass\n- [ ] Manual testing: verify UI updates work correctly when adding documents and tags\n\n🤖 Generated with [Claude Code]\\(https://claude.ai/code\\)\nEOF\n\\)\")",
"Bash(gh pr checks:*)",
"Bash(gh pr merge:*)",
"Bash(git pull:*)",
"Bash(gh pr create --title \"Fix immutable Set bug in ArchiveEntry factory method\" --base main --body \"$\\(cat <<''EOF''\n## Summary\n- Changed `Set.of\\(\\)` to `new HashSet<>\\(\\)` in `ArchiveEntry.of\\(\\)` factory method\n- This allows tags to be added after ArchiveEntry creation without throwing `UnsupportedOperationException`\n\n## Issue\nFixes smartfiles-412\n\n## Test plan\n- [x] All 51 existing tests pass\n\n🤖 Generated with [Claude Code]\\(https://claude.ai/code\\)\nEOF\n\\)\")",
"Bash(gh pr create --title \"Fix PDDocument memory leak by implementing proper resource cleanup\" --base main --body \"$\\(cat <<''EOF''\n## Summary\n- Made `PdfImageRenderer` implement `Closeable` with `close\\(\\)` method that releases PDDocument resources\n- `DocumentView` now closes the previous renderer when switching documents\n- `DocumentView.clear\\(\\)` now closes the current renderer\n- Removed the unbounded renderer cache in favor of tracking only the current renderer\n\n## Issue\nFixes smartfiles-h74\n\n## Test plan\n- [x] All 51 existing tests pass\n- [ ] Manual testing: open multiple PDFs in sequence and verify memory is released\n\n🤖 Generated with [Claude Code]\\(https://claude.ai/code\\)\nEOF\n\\)\")",
"Bash(gh pr create:*)",
"Bash(ls:*)",
"Bash(mvn clean test:*)"
]
},
"enabledPlugins": {
Expand Down
21 changes: 13 additions & 8 deletions src/main/java/dev/arne/smartfiles/app/ApplicationInteractor.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package dev.arne.smartfiles.app;

import dev.arne.smartfiles.core.events.*;
import javafx.application.Platform;
import org.springframework.context.ApplicationListener;

import java.time.format.DateTimeFormatter;
Expand All @@ -11,9 +10,15 @@ public class ApplicationInteractor implements ApplicationListener<SmartFilesEven
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("MMM d, yyyy 'at' HH:mm");

private final ApplicationModel model;
private final FxScheduler scheduler;

public ApplicationInteractor(ApplicationModel model) {
this(model, FxScheduler.platform());
}

public ApplicationInteractor(ApplicationModel model, FxScheduler scheduler) {
this.model = model;
this.scheduler = scheduler;
}

@Override
Expand All @@ -32,33 +37,33 @@ public void onApplicationEvent(SmartFilesEvent event) {
}

private void handleArchiveLastModifiedUpdatedEvent(ArchiveLastModifiedUpdatedEvent e) {
Platform.runLater(() -> model.getArchiveDateLastModifiedProperty().set(e.getLastModified().format(DATE_FORMATTER)));
scheduler.runLater(() -> model.getArchiveDateLastModifiedProperty().set(e.getLastModified().format(DATE_FORMATTER)));
}

private void handleDocumentDeletedEvent(DocumentDeletedEvent e) {
Platform.runLater(() -> model.removeDocument(e.getDocumentId()));
scheduler.runLater(() -> model.removeDocument(e.getDocumentId()));
}

private void handleAllTagsUpdatedEvent(AllTagsUpdatedEvent e) {
Platform.runLater(() -> model.setAllTags(e.getAllTags()));
scheduler.runLater(() -> model.setAllTags(e.getAllTags()));
}

private void handleTagAddedEvent(TagAddedEvent e) {
}

private void handleDocumentTagAddedEvent(DocumentTagAddedEvent e) {
Platform.runLater(() -> model.updateDocumentTags());
scheduler.runLater(() -> model.updateDocumentTags());
}

private void handleDocumentDescriptionUpdatedEvent(DocumentDescriptionUpdatedEvent e) {
Platform.runLater(() -> model.updateDescription(e.getDescription()));
scheduler.runLater(() -> model.updateDescription(e.getDescription()));
}

private void handleLightThemeActivatedSettingsChangedEvent(LightThemeActivatedSettingChangedEvent e) {
Platform.runLater(() -> model.setLightModeActivated(e.isLightThemeActive()));
scheduler.runLater(() -> model.setLightModeActivated(e.isLightThemeActive()));
}

private void handleArchiveEntryAddedEvent(ArchiveEntryAddedEvent e) {
Platform.runLater(() -> model.addDocumentFromArchiveEntry(e.getArchiveEntry()));
scheduler.runLater(() -> model.addDocumentFromArchiveEntry(e.getArchiveEntry()));
}
}
28 changes: 28 additions & 0 deletions src/main/java/dev/arne/smartfiles/app/FxScheduler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package dev.arne.smartfiles.app;

import javafx.application.Platform;

/**
* Functional interface for scheduling work on the JavaFX Application Thread.
* Allows for easy testing by substituting direct execution in test environments.
*/
@FunctionalInterface
public interface FxScheduler {

void runLater(Runnable runnable);

/**
* Returns a scheduler that uses Platform.runLater() for JavaFX Application Thread execution.
*/
static FxScheduler platform() {
return Platform::runLater;
}

/**
* Returns a scheduler that executes runnables directly on the calling thread.
* Useful for testing without a functioning JavaFX runtime.
*/
static FxScheduler direct() {
return Runnable::run;
}
}
183 changes: 183 additions & 0 deletions src/test/java/dev/arne/smartfiles/app/ApplicationInteractorTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package dev.arne.smartfiles.app;

import dev.arne.smartfiles.core.events.*;
import dev.arne.smartfiles.core.model.ArchiveEntry;
import dev.arne.smartfiles.core.model.Tag;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;

import static org.junit.jupiter.api.Assertions.*;

class ApplicationInteractorTest {

private ApplicationModel model;
private ApplicationInteractor interactor;

@BeforeEach
void setUp() {
model = new ApplicationModel();
// Use direct scheduler to execute synchronously without requiring JavaFX runtime
interactor = new ApplicationInteractor(model, FxScheduler.direct());
}

@Test
void handleArchiveEntryAddedEvent_addsDocumentToModel() {
var entry = createTestEntry("test.pdf");

interactor.onApplicationEvent(new ArchiveEntryAddedEvent(entry));

assertEquals(1, model.getDocumentsList().size());
assertEquals("test.pdf", model.getDocumentsList().getFirst().getName());
}

@Test
void handleAllTagsUpdatedEvent_updatesModelTags() {
var tags = Set.of(new Tag("invoice"), new Tag("receipt"));

interactor.onApplicationEvent(new AllTagsUpdatedEvent(tags));

assertEquals(2, model.getAllTagsProperty().size());
assertTrue(model.getAllTagsProperty().stream().anyMatch(t -> t.label().equals("invoice")));
assertTrue(model.getAllTagsProperty().stream().anyMatch(t -> t.label().equals("receipt")));
}

@Test
void handleAllTagsUpdatedEvent_removesInvalidSelectedFilterTags() {
var initialTags = Set.of(new Tag("invoice"), new Tag("receipt"));
model.setAllTags(initialTags);
model.toggleFilterTag(new Tag("invoice"));
assertTrue(model.isFilterTagSelected(new Tag("invoice")));

// Update tags without "invoice"
var newTags = Set.of(new Tag("receipt"));
interactor.onApplicationEvent(new AllTagsUpdatedEvent(newTags));

assertFalse(model.isFilterTagSelected(new Tag("invoice")));
}

@Test
void handleArchiveLastModifiedUpdatedEvent_formatsTimestamp() {
var timestamp = LocalDateTime.of(2024, 6, 15, 14, 30);

interactor.onApplicationEvent(new ArchiveLastModifiedUpdatedEvent(timestamp));

assertEquals("Jun 15, 2024 at 14:30", model.getArchiveDateLastModifiedProperty().get());
}

@Test
void handleDocumentDeletedEvent_removesDocumentFromModel() {
var entry = createTestEntry("test.pdf");
model.getDocumentsList().add(entry);
assertEquals(1, model.getDocumentsList().size());

interactor.onApplicationEvent(new DocumentDeletedEvent(entry.getId()));

assertTrue(model.getDocumentsList().isEmpty());
}

@Test
void handleDocumentDeletedEvent_clearsSelectionIfDeletedDocumentWasSelected() {
var entry = createTestEntry("test.pdf");
model.getDocumentsList().add(entry);
model.setSelectedDocument(entry);
assertEquals("test.pdf", model.getSelectedDocumentNameProperty().get());

interactor.onApplicationEvent(new DocumentDeletedEvent(entry.getId()));

assertNull(model.getSelectedDocumentProperty().get());
assertNull(model.getSelectedDocumentNameProperty().get());
}

@Test
void handleDocumentDescriptionUpdatedEvent_updatesDescription() {
var entry = createTestEntry("test.pdf");
model.getDocumentsList().add(entry);
model.setSelectedDocument(entry);

interactor.onApplicationEvent(new DocumentDescriptionUpdatedEvent(entry.getId(), "New description"));

assertEquals("New description", model.getDescriptionProperty().get());
}

@Test
void handleDocumentTagAddedEvent_updatesTags() {
var entry = createTestEntry("test.pdf");
entry.getTags().add(new Tag("invoice"));
model.getDocumentsList().add(entry);
model.setSelectedDocument(entry);

// Add another tag to the entry (simulating what the service does)
entry.getTags().add(new Tag("receipt"));

interactor.onApplicationEvent(new DocumentTagAddedEvent(new Tag("receipt"), entry.getId()));

assertEquals(2, model.getTagsProperty().size());
}

@Test
void handleLightThemeActivatedSettingsChangedEvent_setsModeActivated_true() {
assertFalse(model.isLightModeActivated());

interactor.onApplicationEvent(new LightThemeActivatedSettingChangedEvent(true));

assertTrue(model.isLightModeActivated());
}

@Test
void handleLightThemeActivatedSettingsChangedEvent_setsModeActivated_false() {
model.setLightModeActivated(true);
assertTrue(model.isLightModeActivated());

interactor.onApplicationEvent(new LightThemeActivatedSettingChangedEvent(false));

assertFalse(model.isLightModeActivated());
}

@Test
void handleTagAddedEvent_completesWithoutError() {
// TagAddedEvent handler is intentionally empty (no-op)
assertDoesNotThrow(() -> interactor.onApplicationEvent(new TagAddedEvent(new Tag("test"))));
}

@Test
void onApplicationEvent_dispatchesToAllEventTypes() {
var entry = createTestEntry("test.pdf");
var tag = new Tag("test");
var timestamp = LocalDateTime.now();

// Set up a selected document for DocumentTagAddedEvent
model.getDocumentsList().add(entry);
model.setSelectedDocument(entry);

// Test all event types dispatch without error
assertDoesNotThrow(() -> {
interactor.onApplicationEvent(new ArchiveEntryAddedEvent(createTestEntry("another.pdf")));
interactor.onApplicationEvent(new AllTagsUpdatedEvent(Set.of(tag)));
interactor.onApplicationEvent(new ArchiveLastModifiedUpdatedEvent(timestamp));
interactor.onApplicationEvent(new DocumentDeletedEvent(UUID.randomUUID()));
interactor.onApplicationEvent(new DocumentDescriptionUpdatedEvent(entry.getId(), "desc"));
interactor.onApplicationEvent(new DocumentTagAddedEvent(tag, entry.getId()));
interactor.onApplicationEvent(new LightThemeActivatedSettingChangedEvent(true));
interactor.onApplicationEvent(new TagAddedEvent(tag));
});
}

private ArchiveEntry createTestEntry(String name) {
var entry = new ArchiveEntry();
entry.setId(UUID.randomUUID());
entry.setName(name);
entry.setSummary("");
entry.setPath("/tmp/" + name);
entry.setAbsolutePath("/tmp/" + name);
entry.setOriginalPath("/orig/" + name);
entry.setTags(new HashSet<>());
entry.setDateCreated(LocalDateTime.now());
entry.setDateLastModified(LocalDateTime.now());
return entry;
}
}
Loading