導入
第6章で作ったgetRemainingCount()は、値を保存せず呼ばれるたびに計算し直す計算プロパティでした。これを実際にLabelへ表示してみましょう。あわせて、一覧が変化したときに、この数字を表示するLabelもちゃんと再描画されるかを確認します。
説明
classDiagram
class TaskListViewModel {
+getTasks() List~TaskDto~
+getRemainingCount() int
}
class Label {
setText("残り " + n + " 件")
}
Label ..> TaskListViewModel : refresh()のたびに再取得
remainingLabel.setText("残り " + viewModel.getRemainingCount() + " 件");
getRemainingCount()はViewModelの内部で値を持たず、呼ばれるたびにservice.remainingCount()を経由して第5章のRepositoryまで辿って数え直します。だから、Controllerがrefresh()のたびにこの値を取り直せば、常に最新の残数が表示されます。
このレッスンでは、この「◯◯件」という表示用の文字列を組み立てる小さなクラスRemainingLabelFormatterを用意し、値の計算とタイミング("remainingCount"の通知)の両方をテストで確認します。
やってみよう
下のエディタのRemainingLabelFormatter.javaには、まだformatの中身がありません。まず「▶ 実行」を押して、どのテストが失敗するか確認してください。
演習
RemainingLabelFormatter.formatを実装してください。"残り {n} 件"という形式の文字列を返します({n}にはviewModel.getRemainingCount()の値を使います)。
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();
}
private void add() {
service.add(newTitle, Priority.MEDIUM);
setNewTitle("");
raisePropertyChanged("tasks");
raisePropertyChanged("remainingCount");
}
}
public class RemainingLabelFormatter {
// TODO: "残り {n} 件" という形式の文字列を返す format を実装してください
public static String format(TaskListViewModel viewModel) {
}
}
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 RemainingLabelFormatterTest {
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 taskGaZeroKenNaraNokoriZeroKenTeidearu() {
assertEquals("残り 0 件", RemainingLabelFormatter.format(viewModel));
}
@Test
void taskWoTsuikaSuruToKazuGaFueru() {
viewModel.setNewTitle("牛乳を買う");
viewModel.getAddCommand().execute();
viewModel.setNewTitle("卵を買う");
viewModel.getAddCommand().execute();
assertEquals("残り 2 件", RemainingLabelFormatter.format(viewModel));
}
@Test
void kanryoSuruToNokoriKazuGaHeru() {
viewModel.setNewTitle("牛乳を買う");
viewModel.getAddCommand().execute();
String id = viewModel.getTasks().get(0).id();
viewModel.complete(id);
assertEquals("残り 0 件", RemainingLabelFormatter.format(viewModel));
}
@Test
void taskGaFueruToRemainingCountNoTsuchiGaTobu() {
List<String> notified = new ArrayList<>();
viewModel.addPropertyChangedListener(name -> notified.add(name));
viewModel.setNewTitle("牛乳を買う");
viewModel.getAddCommand().execute();
assertTrue(notified.contains("remainingCount"), "残数を表示するLabelが再描画されるよう通知される");
}
}
- 期待される結果: 4件のテストがすべて成功
ヒント1を見る
return "残り " + viewModel.getRemainingCount() + " 件";
ヒント2を見る
"tasks"と"remainingCount"の通知は前回までのTaskListViewModelで既に飛んでいます。このレッスンで直すのはformatメソッドだけです
まとめ
- 計算プロパティ(
getRemainingCount())は保存せず、呼ばれるたびに再計算される - Viewからは、状態プロパティも計算プロパティも同じように「呼ぶだけ」で使える
- ここまでで
TaskListViewModelの主要な機能(一覧・追加・完了・残数表示)が、Viewが必要とする形ですべてテストで固まった
次章: DIコンテナ・Composition Root・ヘルパークラスの設計指針など、周辺の道具立てを固めます。