導入
前回までは表示の準備でした。ここからは実際にタスクを追加できるようにします。JavaFXの画面はブラウザ内では描画できないため、代わりに「Viewが呼ぶことになるViewModel側の振る舞い」をテストで固めます。具体的には、入力中の文字が空白ならAddCommandが実行できないこと、そして実行すると一覧が増え、View(ListView)が再描画されるよう通知が飛ぶことです。
図解
sequenceDiagram
participant TF as TextField(View)
participant VM as TaskListViewModel
participant B as Button(AddCommand)
TF->>VM: setNewTitle(文字) が呼ばれる
B->>VM: クリック→AddCommand.execute()
VM->>VM: service.add(...) / newTitleを空に戻す
VM-->>TF: "tasks" の変更を通知(一覧が再描画される)
説明
addButton.setOnAction(e -> {
viewModel.setNewTitle(titleField.getText());
viewModel.getAddCommand().execute();
});
ViewがこのコードでViewModelを呼ぶとき、期待している振る舞いは次の3つです。
setNewTitleで文字を渡すと、getAddCommand().canExecute()が入力の有無で自動的に切り替わる(空白だけならfalse)getAddCommand().execute()を呼ぶと、serviceにタスクが追加され、newTitleが空文字に戻る- 追加が終わったら、
ListViewが再描画されるよう"tasks"の変更が通知される
3つ目が、この章で新しく確認する点です。第6章までは「getTasks()が正しい値を返すか」だけをテストしていましたが、実際にViewへつなぐとなると「値が変わったことをViewに知らせているか」まで確認しないと、ボタンを押しても画面が固まったままになってしまいます。
やってみよう
下のエディタには、これまでの章のクラスがすべてタブで並んでいます。TaskListViewModel.javaのadd()メソッドだけがまだありません。まず何も書かずに「▶ 実行」を押し、どのテストが赤くなるか確認してください。
演習
TaskListViewModelに、追加の実処理を行うadd()メソッドを実装してください。newTitleでタスクを追加し、"tasks"の変更を通知し、newTitleを空文字に戻します。
public interface IdGenerator { String next(); }
public interface Clock { long today(); }
public class TaskTitle {
private final String value;
public TaskTitle(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("タイトルは空にできません");
}
if (value.length() > 50) {
throw new IllegalArgumentException("タイトルは50文字以内です");
}
this.value = value;
}
public String getValue() { return value; }
@Override
public String toString() { return value; }
}
public enum Priority {
LOW("低"), MEDIUM("中"), HIGH("高");
private final String label;
Priority(String label) { this.label = label; }
public String getLabel() { return label; }
}
public class TaskItem {
private final String id;
private TaskTitle title;
private final Priority priority;
private boolean completed;
public TaskItem(String id, TaskTitle title, Priority priority) {
this.id = id;
this.title = title;
this.priority = priority;
}
public String getId() { return id; }
public TaskTitle getTitle() { return title; }
public Priority getPriority() { return priority; }
public boolean isCompleted() { return completed; }
public void complete() { completed = true; }
public void rename(TaskTitle newTitle) { title = newTitle; }
}
public class TaskFactory {
private final IdGenerator idGenerator;
public TaskFactory(IdGenerator idGenerator) {
this.idGenerator = idGenerator;
}
public TaskItem create(String title, Priority priority) {
return new TaskItem(idGenerator.next(), new TaskTitle(title), priority);
}
public TaskItem create(String title) {
return create(title, Priority.MEDIUM);
}
}
import java.util.List;
import java.util.ArrayList;
import java.util.Optional;
public interface TaskRepository {
void add(TaskItem task);
List<TaskItem> findAll();
Optional<TaskItem> findById(String id);
}
public class InMemoryTaskRepository implements TaskRepository {
private final List<TaskItem> tasks = new ArrayList<>();
public void add(TaskItem task) {
tasks.add(task);
}
public List<TaskItem> findAll() {
return tasks;
}
public Optional<TaskItem> findById(String id) {
for (TaskItem task : tasks) {
if (task.getId().equals(id)) return Optional.of(task);
}
return Optional.empty();
}
}
import java.util.List;
import java.util.ArrayList;
public record TaskDto(String id, String title, String priorityLabel, boolean completed) { }
public class TaskMapper {
public static TaskDto toDto(TaskItem task) {
return new TaskDto(task.getId(), task.getTitle().getValue(), task.getPriority().getLabel(), task.isCompleted());
}
}
public class TaskService {
private final TaskRepository repository;
private final TaskFactory factory;
public TaskService(TaskRepository repository, TaskFactory factory) {
this.repository = repository;
this.factory = factory;
}
public void add(String title, Priority priority) {
TaskItem task = factory.create(title, priority);
repository.add(task);
}
public void complete(String id) {
repository.findById(id).ifPresent(task -> task.complete());
}
public List<TaskDto> list() {
List<TaskDto> result = new ArrayList<>();
for (TaskItem task : repository.findAll()) {
result.add(TaskMapper.toDto(task));
}
return result;
}
public int remainingCount() {
int count = 0;
for (TaskItem task : repository.findAll()) {
if (!task.isCompleted()) count = count + 1;
}
return count;
}
}
import java.util.List;
import java.util.ArrayList;
public interface PropertyChangedListener {
void onPropertyChanged(String propertyName);
}
public abstract class ViewModelBase {
private final List<PropertyChangedListener> listeners = new ArrayList<>();
public void addPropertyChangedListener(PropertyChangedListener listener) {
listeners.add(listener);
}
protected void raisePropertyChanged(String propertyName) {
for (PropertyChangedListener listener : listeners) {
listener.onPropertyChanged(propertyName);
}
}
}
import java.util.function.BooleanSupplier;
public interface Command {
void execute();
default boolean canExecute() { return true; }
}
public class RelayCommand implements Command {
private final Runnable action;
private final BooleanSupplier canExecute;
public RelayCommand(Runnable action) {
this(action, () -> true);
}
public RelayCommand(Runnable action, BooleanSupplier canExecute) {
this.action = action;
this.canExecute = canExecute;
}
public void execute() {
action.run();
}
public boolean canExecute() {
return canExecute.getAsBoolean();
}
}
import java.util.List;
public class TaskListViewModel extends ViewModelBase {
private final TaskService service;
private String newTitle = "";
private final Command addCommand = new RelayCommand(() -> add(), () -> canAdd());
public TaskListViewModel(TaskService service) {
this.service = service;
}
public String getNewTitle() { return newTitle; }
public void setNewTitle(String value) {
newTitle = value;
raisePropertyChanged("newTitle");
raisePropertyChanged("canAdd");
}
public List<TaskDto> getTasks() {
return service.list();
}
public int getRemainingCount() {
return service.remainingCount();
}
public Command getAddCommand() {
return addCommand;
}
public void complete(String id) {
service.complete(id);
raisePropertyChanged("tasks");
raisePropertyChanged("remainingCount");
}
private boolean canAdd() {
return newTitle != null && !newTitle.isBlank();
}
// TODO: newTitle でタスクを追加し、"tasks" の変更を通知し、
// 追加が終わったら newTitle を空文字に戻す add() を実装してください
}
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import java.util.ArrayList;
public class TaskListViewModelTest {
private TaskListViewModel viewModel;
@BeforeEach
void setUp() {
IdGenerator idGenerator = () -> "id-1";
TaskRepository repository = new InMemoryTaskRepository();
TaskFactory factory = new TaskFactory(idGenerator);
TaskService service = new TaskService(repository, factory);
viewModel = new TaskListViewModel(service);
}
@Test
void newTitleGaKuuhakuNaraAddCommandHaJikkoDekinai() {
viewModel.setNewTitle(" ");
assertFalse(viewModel.getAddCommand().canExecute(), "空白だけのタイトルでは追加できない");
}
@Test
void newTitleGaAreba_AddCommandHaJikkoDekiru() {
viewModel.setNewTitle("牛乳を買う");
assertTrue(viewModel.getAddCommand().canExecute(), "タイトルがあれば追加できる");
}
@Test
void addCommandWoJikkoSuruToIchiRanNiFueTsuchiSareru() {
List<String> notified = new ArrayList<>();
viewModel.addPropertyChangedListener(name -> notified.add(name));
viewModel.setNewTitle("牛乳を買う");
viewModel.getAddCommand().execute();
assertEquals(1, viewModel.getTasks().size(), "一覧が1件増える");
assertEquals("", viewModel.getNewTitle(), "追加後は入力欄が空に戻る");
assertTrue(notified.contains("tasks"), "Viewが一覧を再描画できるよう\"tasks\"の変更が通知される");
}
}
- 期待される結果: 3件のテストがすべて成功
ヒント1を見る
private void add() { service.add(newTitle, Priority.MEDIUM); setNewTitle(""); raisePropertyChanged("tasks"); raisePropertyChanged("remainingCount"); }
ヒント2を見る
setNewTitle("")を呼ぶと"newTitle"と"canAdd"の通知は自動でついてくるので、自分でraisePropertyChanged("tasks")を追加で呼べば十分です
まとめ
TextFieldの入力はsetNewTitleで伝え、ButtonのクリックはgetAddCommand().execute()で伝える。これがViewからViewModelを呼ぶときの決まった形canExecuteがfalseの間、Viewはボタンを自動的に押せなくする(ControllerがsetDisableで反映する)- 状態が変わったら
raisePropertyChangedで通知する。これを忘れると、処理自体は成功していても画面が更新されない
次回: 一覧から完了操作をつなぎ、CanExecuteでボタンの活性/非活性を制御します。