🏴 우테코 프리코스 과제 3 - 로또
요즘 이것저것 너무 바쁘다. 배울수록 나는 참 부족한 사람이구나 느끼게 된다. 🥲 시험이 끝나도 할 일이 많으니 좋은 건가. 하하. 그래도 힘들다기보다는 잘하고 싶다, 재밌다는 생각이 든다. 최근에 자신감을 가지란 교수님의 조언을 들었다. 이제까지 내 자신을 칭찬보다는 스스로에게 모진말을 하고 구박하며 달려왔었다. 그러나 내가 간절히 원하던 무언갈 손에 쥐고 느낀건, 성취한 찰나의 기쁨도 크지만, 거기까지의 도달 과정에도 충분한 기쁨이 있다는 거다. 어쩌면 목표를 향해 달려갈때의 불안하고 간절한 감정만큼 희망찬 감정은 없을지도 몰라.
🪜 지난 과제 피드백 & 회고
지난 과제에서는 TDD 방식의 개발을 처음으로 도전해봤다. 어설프고 아쉬운 점이 많았다.사실 테스트코드를 먼저짜고 코드를 작성한다는 것 자체가 무척이나 생소해서, 코드적인 부분보다 이러한 프로세스 자체가 무척 어렵게 느껴졌다. 하하... 이번 공통 피드백에서 제일 와닿은 점은 아래와 같다.
*기능 목록을 재검토한다
기능 목록을 클래스 설계와 구현, 함수(메서드) 설계와 구현과 같이 너무 상세하게 작성하지 않는다. 클래스 이름, 함수(메서드) 시그니처와 반환값은 언제든지 변경될 수 있기 때문이다. 너무 세세한 부분까지 정리하기보다 구현해야 할 기능 목록을 정리하는 데 집중한다. 정상적인 경우도 중요하지만, 예외적인 상황도 기능 목록에 정리한다. 특히 예외 상황은 시작 단계에서 모두 찾기 힘들기 때문에 기능을 구현하면서 계속해서 추가해 나간다.
*기능 목록을 업데이트한다
README.md 파일에 작성하는 기능 목록은 기능 구현을 하면서 변경될 수 있다. 시작할 때 모든 기능 목록을 완벽하게 정리해야 한다는 부담을 가지기보다 기능을 구현하면서 문서를 계속 업데이트한다. 죽은 문서가 아니라 살아있는 문서를 만들기 위해 노력한다.
*함수가 한 가지 기능을 하는지 확인하는 기준을 세운다
만약 여러 함수에서 중복되어 사용되는 코드가 있다면 함수 분리를 고민해 본다. 또한, 함수의 길이를 15라인을 넘어가지 않도록 구현하며 함수를 분리하는 의식적인 연습을 할 수 있다.
함수가 한 가지 기능을 하도록 하는건 간단히 되는것 아닌가? 🤔 라고 쉽게 여겼지만... 개발을 하다보니 생각보다 한 함수에 여러 기능이 포함되는 경우가 생겼다.그래서 더더욱 확실한 기준을 세우는 것이 중요함을 느꼈다; 이번 프로젝트에선 이를 최대한 분리하도록 노력하겠다! 그리고 기능 목록을 계속 업데이트하는 따끈한 문서를 만들기로 결심 🫡 !
🚀 기능 요구 사항 분석
기능을 구현하기 전, 프로그램 실행 시나리오를 보며 기능 목록을 만들어 문서에 작성했다. 이번에 달라진 부분은 에러 처리인데, 입력 에러 발생 부분부터 입력을 다시 받아야 한다는 것이다.
◼️구입금액 입력 받기
◼️ 입력된 구입 금액이 1,000원 단위이고 0보다 큰지 확인
◼️ 1,000원으로 나누어 떨어지지 않는 경우 에러 메시지("[ERROR]"로 시작) 출력 후 재입력 요구
◼️ 입력받은 후 'N개를 구매했습니다' 문구 출력하기
◼️ 구입 금액에 해당하는 로또 수량 발급하기
◼️ 발행된 로또 숫자리스트 N개 반환.
◼️ 1개의 로또를 발행할 때 중복되지 않는 6개의 숫자를 랜덤 뽑기
◼️ 당첨 번호 추첨 시 중복되지 않는 숫자 6개와 별도의 보너스 번호 1개를 뽑기
◼️ 1~45 사이의 숫자인지, 중복되는 번호가 없는지 확인
◼️ 당첨 통계 출력하기
◼️ 3개, 4개, 5개, 5개 + 보너스, 6개 일치하는 로또 수를 반환하기
◼️ 당첨결과에 따른 수익률 계산하여 출력하기.소수점 둘째 자리에서 반올림하여 출력
◼️ 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException를 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력
◼️ 입력 에러 발생 부분부터 입력을 다시 받기
🎯 프로그래밍 요구 사항 분석
이번 주차에 추가된 요구 사항에서 특히 주목한 부분은 다음과 같다.
*함수(또는 메서드)의 길이가 15라인을 넘어가지 않도록 구현해야 하고, 함수(또는 메서드)가 한 가지 일만 잘 하도록 구현해야 한다.
*else 예약어를 쓰지 않는다. (if 조건절에서 값을 return하는 방식으로 구현하면 else를 사용하지 않아도 된다.)
*Java Enum을 적용해야 한다.
*도메인 로직에 단위 테스트를 구현해야 한다. 단, UI(System.out, System.in, Scanner) 로직은 제외한다.
*핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 분리해 구현한다.
맨 마지막 조건에 따라 Input과 전체흐름 UI를 담당하는 컨트롤러 클래스 LottoGame.class 를 두었다. 그리고 일단 최소 함수 구현 조 만 염두에 두고, 기본 기능을 완성 후 리팩토링 하며 추가 요구사항을 충족해나가보기로 했다. 일단 저 Enum은 당첨 통계에 적용시키면 될 것 같은 데 🤔...! ? 암튼 작업 시작! 빠이링!
🍒 최초 클래스 기능 명세
📝 Lotto.class | - 한 개의 로또 생성 - 로또 번호 유효성 검사 |
📝 LottoGame.class | - 게임 진행 - 프롬프트 인풋,아웃풋 흐름 관리 |
📝 InputChecker.class | - 사용자 입력 유효성 검사 |
📝 Lottos.class | - 입력된 금액에 해당하는 갯수의 로또 생성 |
📝 LottoResult.class | - 구입한 로또 개수 출력 - 로또 통계 출력 |
📝 LottoProfit.class | - 수익률 계산 |
📝 LottoWinner.class | - 보너스 번호 생성 및 저장 - 당첨 번호 저장 |
📝 Constants.class | 상수 변수 별도 저장 |
클래스를 나눈 기준은 '클래스가 관리하는 기능의 영역을 최소화하자.' 였다. 로또 통계와 수익률 계산 처럼 '이건 한 클래스에 우겨넣어도되지않을까? 🤔' 라고 헷갈릴때도 있었지만 이들도 기능 최소 원칙에따라 아예 클래스를 분리했다.
🔘 코드를 구현하며 배운점 기록
📜 Lottos 일급 컬렉션 적용
📑 domain/Lottos
public class Lottos {
private final List<Lotto> lottos;
public Lottos(List<Lotto> lottos) {
this.lottos = lottos;
}
public static Lottos generateLottos(long lottoCount) {
List<Lotto> generatedLottos = new ArrayList<>();
for (int i = 0; i < lottoCount; i++) {
// List<Integer> numbers = Randoms.pickUniqueNumbersInRange(1, 45, 6);
Lotto lottoNumbers = generateLottoNumbers();
generatedLottos.add(lottoNumbers);
}
return new Lottos(generatedLottos);
}
public int count() {
return lottos.size();
}
public void printLottos() {
for (Lotto lotto : lottos) {
System.out.println(lotto.getNumbers());
}
}
public List<Lotto> getLottos() {
return lottos;
}
}
저번 과제 때 일급 컬렉션이란 개념을 들었는데, 이번 과제를 보고 일급 컬렉션을 적용하기 딱인 과제다! 라는 생각이 들어 적용해보았다. 이렇게 작성하고 보니 장점이 확실히 와닿았다.
위 코드의 Lottos 클래스는 Lotto 객체의 리스트로 이루어진 일급 컬렉션이다. 이를 통해 Lottos 클래스는 -Lotto 객체들을 묶어 효과적으로 관리할 수 있게 되었다. 예시로 위를 보면, Lottos는 비즈니즈 종속적인 자료구조이며, 불변과 모든 관리가 해당 클래스에서 이루어짐을 알 수 있다.
generateLottos 메소드를 통해 지정된 수의 Lotto 객체를 생성하고, Lottos 클래스는 Lotto 객체들을 리스트로 저장하며, count 메소드를 통해 현재 저장된 로또 갯수를 반환하고, printLottos 메소드를 통해 로또의 숫자들을 출력한다. getLottos 메소드는 내부에 저장된 로또 목록을 반환한다.
이렇게 하니 코드가 한결 깔끔해졌다. 😊
📜 자바 Enum 적용
Enum에 대해 알아보던 중, 아래 글을 읽게 되었다. 막연히 가독성 개선의 측면에서 Enum을 염두했었는데, 코드는 코드대로 조회하고 계산은 별도의 클래스&메소드를 통해 진행해야하는 상황들에서도 Enum이 유용하게 쓰일 수 있음을 알 수 있었다.
뿐만 아니라 컴파일 타임에 안정성을 가질 필요가 있다거나, 상수 값이 변화할 가능성이 있는 경우등에도 큰 장점을 가진다고 한다.
https://techblog.woowahan.com/2527/
Java Enum 활용기 | 우아한형제들 기술블로그
{{item.name}} 안녕하세요? 우아한 형제들에서 결제/정산 시스템을 개발하고 있는 이동욱입니다. 이번 사내 블로그 포스팅 주제로 저는 Java Enum 활용 경험을 선택하였습니다. 이전에 개인 블로그에 E
techblog.woowahan.com
여기에는 관련 api가 잘 설명되어 있어 참고해 공부했다. 서로 연관되어 있고,하나의 인스턴스에 모아질 수 있는 상수들... 이번 과제에서는 아무래도 처음 생각났듯 통계에 적용시키는게 맞다고 판단했고,아래와 같이 LottoRanks enum을 새로 생성했다.
📑 constants/LottoRank.enum
public enum LottoRank {
FIFTH("3개",3, 5_000),
FOURTH("4개", 4, 50_000),
THIRD("5개", 5, 1_500_000),
SECOND("5개 일치, 보너스 볼", 5, 30_000_000),
FIRST("6개", 6, 2_000_000_000);
private final String rewardName;
private final int matchingCount;
private final int reward;
LottoRank(String rewardName, int matchingCount, int reward) {
this.rewardName = rewardName;
this.matchingCount = matchingCount;
this.reward = reward;
}
public String getRewardName() {
return rewardName;
}
public int getReward() {
return reward;
}
public static int[] getRewardsAsArray() {
return Arrays.stream(values())
.mapToInt(LottoRank::getReward)
.toArray();
}
public static String[] getRewardNamesAsArray() {
return Arrays.stream(values())
.map(LottoRank::getRewardName)
.toArray(String[]::new);
}
}
🔧 LottoRank(위 코드)와 Enum 적용 전(아래 코드) 코드를 비교해보자.
// LottoResult.class
String[] rewardNames = {"3개", "4개", "5개", "5개 일치, 보너스 볼", "6개"};
int[] reward = {5000,50000,1500000,30000000,2000000000};
Enum을 적용하기 전에는 서로 관련이 있는 코드들을 위와같이 따로 나타냈는데, 이런 서로 관련 있는 상수 값들을 모아 enum으로 구현하여 나타낼 수 있어 유용했다.
// LottoGame.class
String[] rewardNames = getRewardNamesAsArray(); // 변경 전; lottoResult.getRewardNames();
int[] reward = getRewardsAsArray(); // 변경 전; lottoResult.getReward();
변경 전에는 공용으로 사용하는 데이터임에도 불구하고 한 클래스에 의존했다면, enum이 public static final인 만큼 그 목적에 충실하게 제 역할을 해낼 수 있었다. 더불어, 에러상수 & Prompt 명령 상수 또한 constants폴더로 리팩토링 해 주었다.
📜 추가 요구사항 보충
InputChecker에서, 입력값 오류시 다시 입력 받기와 에러 처리를 위해 클래스를 수정했다. while(true)를 이용해 입력값 오류시 계속 재입력 받도록 해주었다. 그리고 아래와 같이 15줄이 넘어가는 검증 함수를 최소 기능으로 정리했다.
📑 함수 최소기능 원칙 적용 전
private static void validateLottoNumbers(String input) {
String[] numberStrings = input.split(",");
Set<Integer> distinctNumbers = new HashSet<>();
for (String numberString : numberStrings) {
if (!numberString.matches("\\d+")) {
throw new IllegalArgumentException("[ERROR] 숫자만 입력 가능 합니다.");
}
int number = Integer.parseInt(numberString);
if (number < 1 || number > 45) {
throw new IllegalArgumentException("[ERROR] 당첨 로또 번호는 1부터 45 사이의 숫자여야 합니다.");
}
if (!distinctNumbers.add(number)) {
throw new IllegalArgumentException("[ERROR] 중복된 번호는 입력할 수 없습니다.");
}
}
}
📑 함수 최소기능 원칙 적용 후
private static void validateLottoNumbers(String input) {
String[] numberStrings = input.split(",");
Set<Integer> distinctNumbers = new HashSet<>();
for (String numberString : numberStrings) {
checkIfNumeric(numberString);
int number = Integer.parseInt(numberString);
validateLottoRange(number);
validateNoDuplicates(distinctNumbers, number);
}
}
private static void checkIfNumeric(String numberString) {
if (!numberString.matches("\\d+")) {
throw new IllegalArgumentException(WRONG_FORMAT.getErrorMessage());
}
}
private static void validateLottoRange(int number) {
if (number < 1 || number > 45) {
throw new IllegalArgumentException(LOTTO_NUMBERS.getErrorMessage());
}
}
private static void validateNoDuplicates(Set<Integer> distinctNumbers, int number) {
if (!distinctNumbers.add(number)) {
throw new IllegalArgumentException(DUPLICATES.getErrorMessage());
}
}
위와 같이 재사용 가능하게 검증함수들도 모두 최소기능으로 분리했다.
아래 컨트롤러 또한 원칙을 위반하고 너무 많은 기능을 담고 있는것 같아 아예 분리해 주었다.
📑 함수 최소기능 원칙 적용 전 ( start() 15줄 이상)
public void start() {
long userLottoPrice = inputChecker.readLottoPrice();
long lottoCount = lottoResult.lottoNumbersPurchased(userLottoPrice);
printLottoCountPurchased(lottoCount);
Lottos purchasedLottos = generateLottos(lottoCount);
// 당첨,보너스 번호 입력
List<Integer> winningNumbers = inputChecker.readWinningNumbers();
Integer bonusNumber = inputChecker.readBonusNumber();
purchasedLottos.printLottos();
// 결과 통계 계산
lottoResult.calculateRewardStatistics(purchasedLottos,winningNumbers,bonusNumber);
// 수익률 출력
printReturnRate(purchasedLottos);
printRewardStatistics();
}
📑 함수 최소기능 원칙 적용 후 ( start() 15줄 이하)
public void start() {
long userLottoPrice = inputChecker.readLottoPrice();
long lottoCount = lottoResult.lottoNumbersPurchased(userLottoPrice);
printLottoCountPurchased(lottoCount);
Lottos purchasedLottos = generateLottos(lottoCount);
processWinningNumbers(purchasedLottos);
printResults(purchasedLottos);
}
private void processWinningNumbers(Lottos purchasedLottos) {
List<Integer> winningNumbers = inputChecker.readWinningNumbers();
Integer bonusNumber = inputChecker.readBonusNumber();
purchasedLottos.printLottos();
lottoResult.calculateRewardStatistics(purchasedLottos, winningNumbers, bonusNumber);
}
private void printResults(Lottos purchasedLottos) {
private void printResults(Lottos purchasedLottos) {
printRewardStatistics();
printReturnRate(purchasedLottos);
}
WinningNumbers와 bonusNumber, 로또 통계 관련은 processWinningNumbers()에 넣어주고, 최종 결과를 print하는 기능을 가진 함수를 따로 만들어 주었다.
또한, 유저의 입력이 잘못되었을 시 재입력받는 부분이 모두 제대로 구현되어있지 않음을 발견했다. 따라서 아래와 같이 while문과 try-catch를 통해서 재입력을 구현했다.
static Integer readBonusNumber(List<Integer> winningNumbers) {
Integer bonusNumber;
while (true){
try{
System.out.println(BONUS_NUMBER.getPromptMessage());
bonusNumber = Integer.parseInt(Console.readLine());
if(winningNumbers.contains(bonusNumber)) {
throw new IllegalArgumentException(DUPLICATES_BONUS_NUMBERS.getErrorMessage());
}
return bonusNumber;
}catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}
📜 테스트 코드 관련 보충 내용
테스트 구현에서, private과 관련하여 테스트를 어떻게 진행하는지 많은 의문이 있었다. 그러던 중 아래 글을 보게 되었다.
https://mangkyu.tistory.com/235
[Java] Private 메소드를 테스트하는 방법과 이를 지양해야 하는 이유
이번에는 private 메소드를 테스트하는 방법에 대해 알아보도록 하겠습니다. 미리 이 글의 결론을 말씀드리면 private 메소드를 테스트하면 안된다는 것입니다. private 메소드를 테스트하는 코드를
mangkyu.tistory.com
이 글에서는 private 메소드 테스트를 지양해야 하는 이유에 대해 설명한다. private 메소드에 대한 테스트는 깨지기 쉬운 테스트가 되기 때문이라고 한다. private 메소드는 내부를 감추어 클라이언트와의 결합도를 낮춰주는데, 클라이언트인 테스트 클래스가 내부 메소드를 알고 있으니 결합도가 높아진다. 그리고 이는 유지보수할 때 테스트에 대한 비용을 증가시키는 요인이 될 수 있는데, 메소드 이름이나 파라미터 등을 변경할 때 실패하기 쉽게 되기 때문이다.
따라서 나는 public 메소드를 대상으로 Test를 진행했다. 그리고 이 방식이 맞다고 생각하는게, TDD(Test-Driven Development)의 관점에서 볼 때, private 메서드에 대한 테스트를 작성하기보다 public 메서드의 동작을 테스트하는 과정에서 private 메서드의 동작을 검증하게 되기 때문이다.
물론 때에 따라서 private 함수를 검증해야하는 경우도 있겠지만 소규모 로직을 갖춘 해당 프로젝트에 굳이 이런 소모를 할 필요는 없다고 생각했다.
그래서 public method들을 대상으로, unit단위 테스트 & 전체 로직 테스트도 충분하다고 생각되어 이렇게 작업했다.
이렇게 완성된 최종 폴더 구조는 위와 같다.
👀 구현 과정에서의 실수 및 성찰
오랜만에 재밌게 이번 과제를 수행했다. 각종 시험에 바빠서 할 것들에 치이다 늦게 시작했지만, 그만큼 몰입해서 달릴 수 있었다. 우테코 프리코스를 진행하면서 여러 가지를 배우는 것 같다! 사실 함수 최소 기능 원칙은 매번 생각은 했지만 잘 지키지 못했는데, 정확한 기준을 세워 작성하면 좀 더 수월한 것 같다.
그리고 Enum의 쓰임새를 알고 적용해 볼 수 있어 뜻깊었다! enum을 사용하여 관련 있는 상수들을 그룹으로 묶어 효과적으로 관리해볼 수 있는 좋은 과제였다는 생각이 든다. 확실히 enum을 적용하니, 코드의 가독성을 향상시키고 중복을 줄일 수 있었다.
사실 기능만 놓고보면 금방 구현하지만, 주어진 미션에 맞게 신중하게 코드를 고민하고 리팩토링하는 과정에서 실력이 느는 것 같다.그리고 자잘하게 고민되는 부분이 많았는데, 이런 고민점들은 검색을 하며 코드를 구현해나갔다.
예시로,static 키워드를 붙여야 하는 기준에 대해 고민되고 헷갈려서 이를 알아본 후에,나만의 기준을 세웠다. static 키워드는 클래스의 인스턴스 생성과 무관하게 사용될 수 있는데, 인스턴스의 고유 속성이라면 static 키워드를 사용해서는 안된다. 예를 들어, 객체별로 다른 값을 가지는 속성이 있다면 static으로 선언하지 말아야 한다. 그러나, 클래스의 모든 인스턴스가 공유해야 하는 상수 (예: Math.PI)나 동일한 메서드를 모든 인스턴스에서 사용하는 경우에는 static을 적극 사용하는게 좋다. 이러한 경우 static을 사용하여 메모리 사용을 줄일 수 있기 때문이다.
하지만 static을 사용할 때 주의할 점은 클래스의 전역 상태를 공유하므로, 이를 사용하는 것이 적절한지 확인해야 한다는 것이다. 때로는 static으로 선언된 변수나 메서드가 어디서나 접근 가능하기 때문에, 코드의 복잡성과 관리가 어려워질 수 있다. 따라서 이를 복합적으로 고려해야한다.
따라서 나는 세가지 물음을 고려해 판단했다.첫째, 공유할 필요가 있는가? 둘째, 특정한 객체 인스턴스의 상태가 아니라 전반적인 클래스 상태에 관련이 있는가? 셋째, 특정 클래스의 메서드 또는 변수가 객체를 생성하지 않아도 되는가?
더불어, try-catch에 대한 개념이 헷갈려, 블로그 글을 참고해 try-catch 에러처리에 대해 알아보았다.자세한 링크는 아래와 같다.https://coding-factory.tistory.com/280
[Java] 자바 예외처리 Try Catch문 사용법
Error(에러)와 Exception(예외의 차이) 에러(Error)란 컴퓨터 하드웨어의 오동작 또는 고장으로 인해 응용프로그램에 이상이 생겼거나 JVM 실행에 문제가 생겼을 경우 발생하는것을 말합니다. 이 경우
coding-factory.tistory.com
예외는 반드시 Throw를 해주어야 하는건가? 라는 물음이 있었는데 위 글이 그 의문점을 해결해 주었다. throw를 않으면 예외처리를 해주었음에도 불구하고 Main에서는 Exception을 전달받지 못하여 개발자가 예외를 인지못하는 경우가 생긴다. 최상단 클래스를 제외한 나머지 클래스에서의 예외처리는 반드시 Throw를 해주자!
또한 우테코를 하며 가장 크게 느끼는점은 테스트 코드의 중요성을 나날히 실감하고 가고 있다는 거다. 여러 테스트 케이스를 먼저 작성하고(코드가 어렵다면 하다못해 테스트 명세를 먼저 만들고) 코드를 구현하면 개발 목적도 정확해질 뿐더러, 발생하는 여러 에러를 빠르게 발견하고 해결할 수 있었다. 예를 들어 나는 정말 멍청하게도 당첨첨번호 입력받기에서 6자리 검사를 빼먹었는데, 테스트 코드를 통해 이를 발견할 수 있었다.
흠, 이렇게 보니 정말 많이 배웠군! 벌써 다음 과제가 기대된다. 🙂
🔘 최종 제출 완성 코드
https://github.com/hyeonjeong-ko/java-lotto-6/tree/hyeonjeongko
GitHub - hyeonjeong-ko/java-lotto-6: 우테코 프리코스 세번째 과제 java-lotto-6
우테코 프리코스 세번째 과제 java-lotto-6. Contribute to hyeonjeong-ko/java-lotto-6 development by creating an account on GitHub.
github.com
참고 ) 입출력 테스트
package lotto.unitTest;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import lotto.domain.LottoGame;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class LottoGameTest {
public static InputStream setReadLine(String readLine) {
return new ByteArrayInputStream(readLine.getBytes());
}
@Test
void 로또가격_입력받기() {
LottoGame lottoGame = new LottoGame();
String expected = "5000";
InputStream readLine = setReadLine(expected);
System.setIn(readLine);
int actual = readLottoPrice();
assertEquals(Integer.parseInt(expected), actual);
}
}
'개발프로젝트' 카테고리의 다른 글
[개발프로젝트] 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 chapter 02- 테스트 (0) | 2023.12.20 |
---|---|
[개발프로젝트] 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 chapter 01 (0) | 2023.12.19 |
utc4 (2) | 2023.11.15 |
utc2 (0) | 2023.10.31 |
utc1 (1) | 2023.10.24 |