diff --git a/CreditManager.swift b/CreditManager.swift new file mode 100644 index 0000000..3d9caa3 --- /dev/null +++ b/CreditManager.swift @@ -0,0 +1,267 @@ +// +// CreditManager.swift +// Git_ Exercise +// +// Created by sei_dev on 12/19/22. +// + +import Foundation + +/// creditManager의 DataFile 관련 정보들 +enum DataFile { + /// 데이터가 저장되고 로드하는 json 파일 이름 + static let name = "data.json" + /// 파일이 저장될 Directory + static let directory: String = FileManager.default.currentDirectoryPath + static let pathString = DataFile.directory.appending(DataFile.name) + static var pathUrl: URL { + if #available(macOS 13.0, *) { + return URL(filePath: DataFile.directory.appending(DataFile.name)) + } else { + return URL(fileURLWithPath: DataFile.directory.appending(DataFile.name)) + } + } +} + +final class CreditManager { + /// CreditManager - singleton + static let shared = CreditManager() + private init() { } + + /// CreditManager의 현재 status + private var status: Status = .start + /// CreditManager에 등록된 Student의 List + private var students = [Student]() + + /// 1. 저장된 데이터 로드 + /// 2. 프로그램 상태에 맞는 info 메세지 출력 + /// 3. input 처리 및 실행 + /// 4. error발생 시 설명 출력 + /// - 종료 error 시 데이터 저장 및 프로그램 종료 + func run() { + loadData() + while true { + do { + IOManager.writeMessage(status.infoMessage) + let input = try IOManager.getInput(isStartStatus: status == .start) + let parsedInput = try parse(input: input) + try doWith(parsedInput) + } catch { + IOManager.writeMessage(error.localizedDescription, type: .error) + switch error { + case CMError.quitProgram: + saveData() + return + default: + status = .start + } + } + } + } + + /// status 별 input parse + private func parse(input: String) throws -> ParsedInput { + guard let parsedInput = status.parse(input: input) else { + throw status == .start ? CMError.invalidStartInput : IOError.wrongInput + } + return parsedInput + } + + + /// 입력과 상태에 따라 CreditManager 작동 분기 + /// + /// - result Infomation이 있다면 출력한다 + /// - 실행 후 credit manager의 status는 start로 변경 + private func doWith(_ input: ParsedInput) throws { + do { + switch self.status { + case .start: + try start(input) + return + case .addStudent: + try add(student: input) + case .deleteStudent: + try delete(student: input) + case .addScore: + try add(score: input) + case .deleteScore: + try delete(score: input) + case .showScoreAverage: + try show(score: input) + case .exit: + return + } + } catch { + throw error + } + if let resInfo = status.resMessage(input: input) { + IOManager.writeMessage(resInfo, type: .reaction) + } + status = .start + } +} + +//MARK: - Start + +extension CreditManager { + /// 입력에 따라 credit manager의 status 변경 + private func start(_ input: ParsedInput) throws { + guard let nextStatus = input[0] as? Status else { + throw CMError.invalidStartInput + } + guard nextStatus != .exit else { + throw CMError.quitProgram + } + guard [Status.start, Status.addStudent].contains(nextStatus) || false == students.isEmpty else { + throw CMError.emptyStudents + } + self.status = nextStatus + } +} + +//MARK: - AddStudent + +extension CreditManager { + /// 입력받은 학생을 students에 추가 + /// + /// Error occurs when + /// - 입력받은 학생이 이미 존재 + private func add(student input: ParsedInput) throws { + guard let name = input[0] as? String else { + throw IOError.wrongInput + } + guard false == exists(student: name) else { + throw CMError.studentAleadyExists(name: name) + } + let student = Student(name: name) + students.append(student) + } + + /// 학생이 등록되어 있다면 true + private func exists(student name: String) -> Bool { + return students.contains { $0.name == name } + } +} + +//MARK: - Delete Student + +extension CreditManager { + /// 입력받은 학생을 students에서 삭제 + /// + /// Error occurs when + /// - 등록된 학생이 0명 + /// - 입력받은 학생이 존재하지 않음 + private func delete(student input: ParsedInput) throws { + guard false == students.isEmpty else { + throw CMError.emptyStudents + } + guard let name = input[0] as? String else { + throw IOError.wrongInput + } + guard exists(student: name) else { + throw CMError.studentNotFound(name: name) + } + students.removeAll { $0.name == name } + } +} + + +//MARK: - Add Score + +extension CreditManager { + /// 등록된 학생에 course, score 정보 추가 + /// + /// Error occurs when + /// - 입력받은 학생이 존재하지 않음 + private func add(score input: ParsedInput) throws { + guard let name = input[0] as? String, + let course = input[1] as? String, + let score = input[2] as? Score else { + throw IOError.wrongInput + } + guard let student = students.first(where: { $0.name == name}) else { + throw CMError.studentNotFound(name: name) + } + student.update(course: course, score: score) + } +} + +//MARK: - Delete Score + +extension CreditManager { + /// 등록된 학생의 course 정보 삭제 + /// + /// Error occurs when + /// - 입력받은 학생이 존재하지 않음 + /// - 학생 정보에 해당 과목이 등록되어 있지 않음 + private func delete(score input: ParsedInput) throws { + guard let name = input[0] as? String, + let course = input[1] as? String else { + throw IOError.wrongInput + } + guard let student = students.first(where: { $0.name == name}) else { + throw CMError.studentNotFound(name: name) + } + guard student.remove(course: course) != nil else { + throw CMError.courseNotFound(name: name, course: course) + } + } +} + +//MARK: - Show Average Score + +extension CreditManager { + /// 등록된 학생의 전체 과목과 평점 확인 + /// + /// Error occurs when + /// - 입력받은 학생이 존재하지 않음 + /// - 학생 정보에 등록된 과목이 없음 + private func show(score input: ParsedInput) throws { + guard let name = input[0] as? String else { + throw IOError.wrongInput + } + guard let student = students.first(where: { $0.name == name}) else { + throw CMError.studentNotFound(name: name) + } + guard false == student.scores.isEmpty else { + throw CMError.emptyCourse(name: name) + } + IOManager.writeMessage(student.allScoresDescription) + } +} + + +extension CreditManager { + /// students property를 json 파일에 저장 + private func saveData() { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + do { + let data = try encoder.encode(StudentList(students: students)) + + if false == FileManager.default.fileExists(atPath: DataFile.pathString) { + FileManager.default.createFile(atPath: DataFile.pathString, contents: nil) + } + + try data.write(to: DataFile.pathUrl) + let names = students.map {$0.name} + + IOManager.writeMessage(Info.Data.saved(names: names), type: .reaction) + } catch { + IOManager.writeMessage(error.localizedDescription, type: .error) + } + } + + /// json 파일 데이터를 students property에 불러오기 + private func loadData() { + guard let data = try? Data(contentsOf: DataFile.pathUrl), + let studentList = try? JSONDecoder().decode(StudentList.self, from: data) else { + IOManager.writeMessage(Info.Warning.failedToLoadData, type: .error) + return + } + students = studentList.students + + let names = students.map {$0.name} + IOManager.writeMessage(Info.Data.loaded(names: names), type: .reaction) + } +} diff --git a/Error.swift b/Error.swift new file mode 100644 index 0000000..34f38f0 --- /dev/null +++ b/Error.swift @@ -0,0 +1,56 @@ +// +// Error.swift +// Git_ Exercise +// +// Created by sei_dev on 12/19/22. +// + +import Foundation + +//MARK: - CMError + +enum CMError: Error { + case invalidStartInput + case studentAleadyExists(name: String) + case studentNotFound(name: String) + case emptyCourse(name: String) + case courseNotFound(name: String, course:String) + case emptyStudents + case quitProgram +} + +extension CMError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidStartInput: + return "입력이 잘못되었습니다. 1~5 사이의 숫자 혹은 X를 입력해주세요." + case let .studentAleadyExists(name): + return "\(name): 이미 존재하는 학생입니다. 추가하지 않습니다." + case let .studentNotFound(name): + return "\(name) 학생을 찾지 못했습니다." + case let .courseNotFound(name, course): + return "\(name) - \(course): 등록되지 않은 과목입니다." + case let .emptyCourse(name): + return "\(name): 등록된 과목이 없습니다." + case .emptyStudents: + return "등록된 학생이 존재하지 않습니다." + case .quitProgram: + return "프로그램을 종료합니다..." + } + } +} + +//MARK: - IOError + +enum IOError: Error { + case wrongInput +} + +extension IOError: LocalizedError { + public var errorDescription: String? { + switch self { + case .wrongInput: + return "입력이 잘못되었습니다. 다시 확인해주세요." + } + } +} diff --git a/IOManager.swift b/IOManager.swift new file mode 100644 index 0000000..d8ec114 --- /dev/null +++ b/IOManager.swift @@ -0,0 +1,44 @@ +// +// IOManager.swift +// Git_ Exercise +// +// Created by sei_dev on 12/19/22. +// + +import Foundation + +/// Console output type +enum OutputType { + case information + case reaction + case error +} + +enum IOManager { + static let nameRegex = "^[A-Za-z0-9_]+$" + + /// 사용자 입력 받기 + static func getInput(isStartStatus: Bool) throws -> String { + guard let input = readLine(), + input != "" else { + throw isStartStatus ? CMError.invalidStartInput : IOError.wrongInput + } + return input + } + + static func checkValidity(of str: String) -> Bool { + return str.range(of: Self.nameRegex, options: .regularExpression, range: nil, locale: nil) != nil + } + + /// message 콘솔에 출력 + static func writeMessage(_ message: String, type: OutputType = .information) { + switch type { + case .information: + print("> \(message)") + case .reaction: + print("< \(message)") + case .error: + fputs("⚠️ \(message)\n", stderr) + } + } +} diff --git a/Info.swift b/Info.swift new file mode 100644 index 0000000..77bfaea --- /dev/null +++ b/Info.swift @@ -0,0 +1,63 @@ +// +// Info.swift +// Git_ Exercise +// +// Created by sei_dev on 12/19/22. +// + +import Foundation + +enum Info { + static let start = """ + 원하는 기능을 입력해주세요. + 1: 학생 추가, 2: 학생 삭제, 3: 성적 추가(변경), 4: 성적 삭제, 5: 평점 보기, X: 종료 + """ + enum Student { + static let forAdd = "추가할 학생의 이름을 입력해주세요." + static func added(name: String) -> String { + return "\(name) 학생을 추가했습니다." + } + static let forDelete = "삭제할 학생의 이름을 입력해주세요." + static func deleted(name: String) -> String { + return "\(name) 학생을 삭제했습니다." + } + } + enum Score { + static let forAdd = """ + 성적을 추가할 학생의 이름, 과목 이름, 성적(A+, A, F 등)을 띄어쓰기로 구분하여 차례로 작성해주세요. + 입력 예) Mickey Swift A+ + 학생의 성적 중 해당 과목이 존재하면 기존 점수가 갱신됩니다. + """ + static func added(name:String, course: String, score: String) -> String { + return "\(name) 학생의 \(course) 과목이 \(score)로 추가(변경)되었습니다." + } + static let forDelete = """ + 성적을 삭제할 학생의 이름, 과목 이름을 띄어쓰기로 구분하여 차례로 작성해주세요. + 입력 예) Mickey Swift + """ + static func deleted(name:String, course:String) -> String { + return "\(name) 학생의 \(course) 과목의 성적이 삭제되었습니다." + } + static let forAverage = "평점을 알고 싶은 학생의 이름을 입력해주세요." + } + enum Data { + static func saved(names: [String]) -> String { + return names.isEmpty ? "저장할 학생 정보가 없습니다." + : """ + 학생과 성적 정보를 성공적으로 저장했습니다. + 저장된 학생: \(names.joined(separator: ", ")) + """ + } + static func loaded(names: [String]) -> String { + return """ + 학생과 성적 정보를 성공적으로 불러왔습니다. + 불러온 학생: \(names.joined(separator: ", ")) + """ + } + } + + enum Warning { + static let failedToLoadData = "기존의 Data를 불러오는 데 실패했습니다." + static let failedToLoadReaction = "실행 결과 안내 메세지를 불러오는 데 실패했습니다." + } +} diff --git a/Score.swift b/Score.swift new file mode 100644 index 0000000..ba572c4 --- /dev/null +++ b/Score.swift @@ -0,0 +1,68 @@ +// +// Score.swift +// Git_ Exercise +// +// Created by sei_dev on 12/19/22. +// + +import Foundation + +enum Score: Float, Codable { + case APlus = 4.5 + case A = 4 + case BPlus = 3.5 + case B = 3 + case CPlus = 2.5 + case C = 2 + case DPlus = 1.5 + case D = 1 + case F = 0 + + var description: String { + switch self { + case .APlus: + return "A+" + case .A: + return "A0" + case .BPlus: + return "B+" + case .B: + return "B0" + case .CPlus: + return "C+" + case .C: + return "C0" + case .DPlus: + return "D+" + case .D: + return "D0" + case .F: + return "F" + } + } + + init?(score: String) { + switch score { + case "A+", "a+", "4.5": + self = .APlus + case "A", "a", "A0", "a0", "4", "4.0": + self = .A + case "B+", "b+", "3.5": + self = .BPlus + case "B", "b", "B0", "b0", "3", "3.0": + self = .B + case "C+", "c+", "2.5": + self = .CPlus + case "C", "c", "C0", "c0", "2", "2.0": + self = .C + case "D+", "d+", "1.5": + self = .DPlus + case "D", "d", "D0", "d0", "1", "1.0": + self = .D + case "F", "f", "0", "0.0": + self = .F + default: + return nil + } + } +} diff --git a/Status.swift b/Status.swift new file mode 100644 index 0000000..5a332b1 --- /dev/null +++ b/Status.swift @@ -0,0 +1,114 @@ +// +// Status.swift +// Git_ Exercise +// +// Created by sei_dev on 12/19/22. +// + +import Foundation + +typealias ParsedInput = [Codable] + +/// status 정보 +enum Status: String, Codable { + case start = "0" + case addStudent = "1" + case deleteStudent = "2" + case addScore = "3" + case deleteScore = "4" + case showScoreAverage = "5" + case exit = "X" +} + +//MARK: - Info Message + +extension Status { + var infoMessage: String { + switch self { + case .start: + return Info.start + case .addStudent: + return Info.Student.forAdd + case .deleteStudent: + return Info.Student.forDelete + case .addScore: + return Info.Score.forAdd + case .deleteScore: + return Info.Score.forDelete + case .showScoreAverage: + return Info.Score.forAverage + default: + return "" + } + } +} + +//MARK: - Result Message + +extension Status { + func resMessage(input: ParsedInput) -> String? { + switch self { + case .addStudent: + if let name = input[0] as? String { + return Info.Student.added(name: name) + } + case .deleteStudent: + if let name = input[0] as? String { + return Info.Student.deleted(name: name) + } + case .addScore: + if let name = input[0] as? String, + let course = input[1] as? String, + let score = input[2] as? Score { + return Info.Score.added(name: name, course: course, score: score.description) + } + case .deleteScore: + if let name = input[0] as? String, + let course = input[1] as? String { + return Info.Score.deleted(name: name, course: course) + } + default: + return nil + } + return Info.Warning.failedToLoadReaction + } +} + +//MARK: - Status - parse method + +extension Status { + /// status에 따라 사용자 입력 parse + func parse(input: String) -> ParsedInput? { + let splited = input.components(separatedBy: " ") + switch (self, splited.count) { + case (.start, 1): + guard let status = Status(rawValue: splited[0]) else { + return nil + } + return [status] + case (.addStudent, 1), (.deleteStudent, 1), (.showScoreAverage, 1): + guard IOManager.checkValidity(of: splited[0]) else { + return nil + } + return splited + case (.addScore, 3): + guard IOManager.checkValidity(of: splited[0]), + IOManager.checkValidity(of: splited[1]), + let score = Score(score: splited[2]) else { + return nil + } + var res = [Codable]() + res.append(contentsOf: splited) + res[2] = score + return res + case (.deleteScore, 2): + guard IOManager.checkValidity(of: splited[0]), + IOManager.checkValidity(of: splited[1]) else { + return nil + } + return splited + default: + return nil + } + } +} diff --git a/Student.swift b/Student.swift new file mode 100644 index 0000000..245d355 --- /dev/null +++ b/Student.swift @@ -0,0 +1,48 @@ +// +// Student.swift +// Git_ Exercise +// +// Created by sei_dev on 12/19/22. +// + +import Foundation + +final class Student: Codable { + let name: String + private(set) var scores = [String:Score]() + + var isCourseEmpty: Bool { + return scores.isEmpty + } + + var averageScore: Float { + let total = scores.values.reduce(0) { $0 + $1.rawValue } + return total/Float(scores.values.count) + } + + var allScoresDescription: String { + var res = "" + for item in scores { + res += "\(item.key): \(item.value.description)\n" + } + return res + "평점: " + String(format: "%.2f", averageScore) + } + + init(name: String) { + self.name = name + } + + func update(course: String, score: Score) { + scores.updateValue(score, forKey: course) + } + + func remove(course: String) -> Score? { + return scores.removeValue(forKey: course) + } +} + +//MARK: - Student DataStructure + +struct StudentList: Codable { + let students: [Student] +} diff --git a/main.swift b/main.swift new file mode 100644 index 0000000..11d507d --- /dev/null +++ b/main.swift @@ -0,0 +1,11 @@ +// +// main.swift +// Git_ Exercise +// +// Created by sei_dev on 2022/12/18. +// + +import Foundation + +let cm = CreditManager.shared +cm.run()