SRS/log.json

1 line
42 KiB
JSON

[{"root":"C:\\Users\\Monoid\\Desktop\\SRS\\SRS","config":{"book":{"authors":["monoid"],"language":"ko","multilingual":false,"src":"src","title":"Software Requirement Specification"},"output":{"html":{"additional-js":["mermaid.min.js","mermaid-init.js"],"live-reload-endpoint":"__livereload","site-url":"/"}},"preprocessor":{"etap":{"before":["mermaid"],"command":"deno run -A --no-check tools/preprop.ts"},"mermaid":{"command":"mdbook-mermaid"}}},"renderer":"html","mdbook_version":"0.4.18"},{"sections":[{"Chapter":{"name":"Index","content":"# 목차(Index)\r\n\r\n1. [Introduction](./intro.md)\r\n 1. [목적(Purpose)](./intro.md#11-목적purpose)\r\n 2. [범위(scope)](./intro.md#12-범위scope)\r\n 3. [용어 및 약어 정의(Definitions, acronyms and abbreviations)](./intro.md#13-용어-및-약어-정의definitions-acronyms-and-abbreviations)\r\n 4. [참고자료(References)](./intro.md#14-참고자료references)\r\n 5. [개요(Overview)](./intro.md#15-개요overview)\r\n2. [Overall Description](./overall.md)\r\n 1. [제품 관점(Product perspective)](./overall.md#21-제품-관점product-perspective)\r\n 1. [시스템 인터페이스(System interfaces)](./overall.md#211-시스템-인터페이스system-interfaces)\r\n 2. [사용자 인터페이스(User interfaces)](./overall.md#212-사용자-인터페이스user-interfaces)\r\n 3. [하드웨어 인터페이스(Hardware interfaces)](./overall.md#213-하드웨어-인터페이스hardware-interfaces)\r\n 4. [소프트웨어 인터페이스(Software interfaces)](./overall.md#214-소프트웨어-인터페이스software-interfaces)\r\n 5. [통신 인터페이스(Communications interfaces)](./overall.md#215-통신-인터페이스communications-interfaces)\r\n 6. [메모리 제약사항(Memory constraints)](./overall.md#216-메모리-제약사항memory-constraints)\r\n 7. [운영(Operations)](./overall.md#217-운영operations)\r\n 8. [사이트 적용 요건(Site adaption requirements)](./overall.md#218-사이트-적용-요건site-adaption-requirements)\r\n 2. [제품 기능(Product functions)](./overall.md#22-제품-기능product-functions)\r\n <%\r\n const table = it.table;\r\n let index = 1;\r\n for (const [c,issues] of table) {\r\n const name = `${c} Operation`;\r\n const href = `2.2.${index} ${c} Operation`\r\n %><%= `${index++}. [${name}](./overall.md#${it.toHeadId(href)})\\n ` %><%}%><%=\"\\n\"%>\r\n3. [Specific Requirement](./specific.md)\r\n 1. [외부 인터페이스 요구사항(External interface requirements)](./specific.md#31-외부-인터페이스-요구사항external-interface-requirements)\r\n 2. [기능 요구사항(Functional requirements)](./specific.md#32-기능-요구사항functional-requirements)\r\n <%= it.issues.map((i)=>`(#${i.number}) ${i.title}`).map(\r\n x=>`* [${x}](./specific.md#${it.toHeadId(x)})`\r\n ).join(\"\\n \") %><%= \"\\n\"%>\r\n 3. [성능 요구사항(Performance requirements)](./specific.md#33-성능-요구사항performance-requirements)\r\n 4. [논리적 데이터베이스 요구사항(Logical database requirements)](./specific.md#34-논리적-데이터베이스-요구사항logical-database-requirements)\r\n 5. [설계 제약사항(Design constraints)](./specific.md#35-설계-제약사항design-constraints)\r\n 1. [표준 준수(Standards compliance)](./specific.md#351-표준-준수standards-compliance)\r\n 6. [소프트웨어 시스템 속성(Software system attributes)](./specific.md#36-소프트웨어-시스템-속성software-system-attributes)\r\n 7. [상세 요구사항의 구성(Organizing the specific requirements)](./specific.md#37-상세-요구사항의-구성organizing-the-specific-requirements)\r\n 1. [객체(Objects)](./specific.md#371-객체objects)\r\n 2. [사용자 인터페이스 상세](./specific.md#372-사용자-인터페이스-상세)\r\n4. [Supporting information](./support.md)\r\n5. [Architecture](./architecture.md)\r\n6. [Testing](./testing.md)","number":null,"sub_items":[],"path":"index.md","source_path":"index.md","parent_names":[]}},{"Chapter":{"name":"Introduction","content":"# 1. 소개(Introduction)\n\n> Version : 1.0.1\n\n 본 문서는 전북대학교 컴퓨터공학과의 Floor 팀에서 Scrap Yard라는 어플리케이션을 설계 및 구현하기 위한 소프트웨어 요구사항 명세서(SRS)이다.\n\n## 1.1. 목적(Purpose)\n\n본 문서의 목적은 프로젝트의 관련된 모든 아이디어들을 정리하고 분석해서 나열하는 것이다. 또한 프로젝트를 더 잘 이해하기 위해 이 제품이 어떻게 사용될지 예측하고 분류하고, 나중에 개발될 요소를 설명하고, 고려 중이지만 폐기될 수 있는 요구사항들을 문서화한다.\n\n## 1.2. 범위(Scope)\n\n본 문서의 범위는 ScrapYard의 기능들과 그 환경이다.\n\nScrapYard는 문서 작성 밎 문서를 아카이빙 할 수 있는 웹 어플리케이션이다. 같이 제공되는 확장기능을 통해 북마크(즐겨찾기)를 구조적으로 보관할 수 있고 미리보기를 보여줄 수 있다.\n\n또한 문서를 다른 사람과 링크로 공유할 수 있다.\n\n개인정보의 관리를 자기 자신이 제어할 수 있도록 파일과 문서에 대한 메타데이터가 어떠한 외부 DB가 있는 것이 아닌 파일에서 사람이 읽을 수 있는 형태로 관리된다. \n\n## 1.3. 용어 및 약어 정의(Definitions, acronyms and abbreviations)\n\n|용어 및 약어|정의|\n|---|----|\n|ScrapYard|현재 개발하는 앱의 명칭|\n|DnD|드래그 앤 드롭의 약자|\n\n## 1.4. 참고자료(References)\n\n- [repo](https://github.com/vi117/scrap-yard)\n- [react](https://reactjs.org/)\n- [recoil](https://recoiljs.org/)\n- [MUI](https://mui.com/)\n- [dndkit](https://docs.dndkit.com/)\n- [markdown](https://commonmark.org/)\n\n## 1.5. 개요(Overview)\n\n 2장에서는 종합적인 요구사항을 서술하고, 3장에서는 기능 및 UI에 대해서 상세한 요구사항을 설명한다.\n","number":[1],"sub_items":[],"path":"intro.md","source_path":"intro.md","parent_names":[]}},{"Chapter":{"name":"Overall Description","content":"# 2. 전체 시스템 개요(Overall description)\n\n### 2.1. 제품 관점(Product perspective)\n\n### 2.1.1. 시스템 인터페이스(System interfaces)\n\n본 시스템은 Cross-platform 소프트웨어이다. 다음과 같은 브라우저가 원활히 실행될 수 있는 시스템에서 동작 할 수 있다.\n- Chrome 버전 61 이상\n- Firefox 버전 60 이상\n- Edge 버전 79 이상\n- Safari 버전 11 이상\n- Chrome for Android 버전 100 이상\n- Samsung internet 버전 8.2 이상\n\n### 2.1.2. 사용자 인터페이스(User interfaces)\n\n웹으로 동작하는 GUI이다. 키보드와 마우스, 터치 인터페이스로 동작할 수 있다. GUI의 디자인은 Material Design이나 Metro Design 같이 플랫한 디자인을 추구한다.\n\n### 2.1.3. 하드웨어 인터페이스(Hardware interfaces)\n\n해당되지 않음.\n\n### 2.1.4. 소프트웨어 인터페이스(Software interfaces)\n\n이 프로젝트의 결과물은 클립보드를 통해서 여러 타입의 데이터를 import/export한다.\n\n### 2.1.5. 통신 인터페이스(Communications interfaces)\n\n해당되지 않음.\n\n### 2.1.6. 메모리 제약사항(Memory constraints)\n\n서버는 2GB 메모리 환경에서 정상 작동해야 한다.\n\n### 2.1.7. 운영(Operations)\n\n해당되지 않음.\n\n### 2.1.8. 사이트 적용 요건(Site adaption requirements)\n\n해당되지 않음.\n\n## 2.2. 제품 기능(Product functions)\n\n본 프로젝트의 결과물은 다음과 같은 기능을 수행한다.\n\n<%\n const table = it.table;\n let index = 1;\n for (const [c,issues] of table) {\n%><%= `### 2.2.${index++} ${c} Operation\\n\\n` %><%\n let subIndex = 1;\n for (const i of issues) {\n%><%=`${subIndex++}. #${i.number} ${i.title}\\n` %><%\n }\n%>\n\n<%\n }\n%>\n\n## 2.3. 사용자 특성(User characteristics)\n\n사용자는 기본적인 GUI 조작을 할 줄 알며 인터넷 사용을 원활히 할 수 있고 기본적인 영어를 읽고 쓸 줄 알며, markdown을 작성할 수 있는 사용자로 한정한다. 일반적으로 13세 이상 65세 이하의 사람을 사용자로 가정한다.\n\n## 2.4. 제약사항(Constraints)\n\n- 이 프로젝트는 MIT License로 개발되고 있으므로 라이브러리의 라이센스도 신경을 쓴다.\n- XSS 공격에 안전해야 한다.\n\n## 2.5. 가정 및 의존성(Assumptions and dependencies)\n\n해당되지 않음.\n\n## 2.6. 단계별 요구사항(Apportioning of requirements)\n\n해당되지 않음.","number":[2],"sub_items":[],"path":"overall.md","source_path":"overall.md","parent_names":[]}},{"Chapter":{"name":"Specific Requirement","content":"# 3. 상세요구사항(Specific Requirements)\n\n## 3.1. 외부 인터페이스 요구사항(External interface requirements)\n\n해당되지 않음.\n## 3.2. 기능 요구사항(Functional requirements)\n\n<%~ it.issues.map(i => `### (#${i.number}) ${i.title}\\n${i.body.replaceAll(\"\\r\\n\",\"\\n\")}`).join(\"\\n\\n\") %>\n\n## 3.3. 성능 요구사항(Performance requirements)\n\n1. 최소 1000 RPS를 보장해야한다.\n2. 첫 로드후 로딩하면 0.5s 이내에 동작해야합니다\n3. 동시 편집 이용자를 5명까지는 허용해야 합니다.\n4. 적어도 400개의 파일을 관리할 수 있어야 합니다.\n\n## 3.4. 논리적 데이터베이스 요구사항(Logical database requirements)\n\n```mermaid\nerDiagram\n User {\n int id\n string accessToken\n date expiredAt\n }\n User ||--o{ Permission : has\n Permission {\n string name\n string path\n }\n```\n\n 단순하게 공유를 위한 User 구조만 있다.\n\n\n## 3.5. 설계 제약사항(Design constraints)\n\n2.1.1. 에서 언급했듯이 다음 브라우저에서 동작해야 한다.\n\n- Chrome 버전 61 이상\n- Firefox 버전 60 이상\n- Edge 버전 79 이상\n- Safari 버전 11 이상\n- Chrome for Android 버전 100 이상\n- Samsung internet 버전 8.2 이상\n\n그리고 모바일에서도 큰 불편함 없이 원활히 동작해야 한다.\n\n### 3.5.1. 표준 준수(Standards compliance)\n\n해당되지 않음.\n\n## 3.6. 소프트웨어 시스템 속성(Software system attributes)\n\n해당되지 않음.\n\n## 3.7. 상세 요구사항의 구성(Organizing the specific requirements)\n\n### 3.7.1. 객체(Objects)\n\n다음과 같은 UML을 그릴 수 있다.\n\n```mermaid\nclassDiagram\n class Document{\n - URL path\n - string[] tags\n \n + renderChunk()\n + remove()\n + addTag(name: string)\n + deleteTag(name: string)\n + async share(option: ShareOption): URL\n + renderNavigator()\n }\n class Chunk{\n - id_t id\n - Content data\n - bool focused\n - pos_t cursorPos\n\n + focus(index: number)\n + unfocus(index: number)\n + remove(index: number)\n + insertBefore()\n + draw()\n + drawPreview()\n + autoComplete(ctx: AutoCompleteContext)\n + swapWith(p : Chunk)\n + edit()\n }\n Document \"1\" <-- \"n\" Chunk : List\n class Fileview{\n + listDirectory(path: URL)\n + open(path: URL)\n + remove(path: URL)\n + create(path: URL)\n + upload(file: Uint8Array| FileStream)\n + download(path: URL)\n + export(path: URL, option: ExportOption)\n }\n Fileview <.. Document : create\n class StashList{\n Content[] stash\n int maxStash\n createFromServer()\n push(c: Content)\n pop()\n }\n class DnDManager{\n + dropTo(Fileview)\n + dropTo(Document)\n + dropTo(Stash)\n + dragFrom(Fileview)\n + dragFrom(Document)\n + dragFrom(Stash)\n }\n DnDManager <.. Fileview : param\n DnDManager <.. Document : param\n DnDManager <.. StashList : param\n class SearchManager{\n search(query,option)\n }\n SearchManager <.. Document : return\n```\n\n```mermaid\nclassDiagram\n class Management{\n login(auth:Auth)\n setServerConfigure(config: Configure)\n setLocale(lang: string)\n setTheme(theme: string)\n getLocale()\n getTheme()\n getServerConfigure()\n }\n class Extension{\n registerPlugin(plugin: Plugin)\n }\n```\n\nChunk는 문서를 이루는 기본적인 단위이다. 글의 문단이라고 생각 할 수 있다. Document는 그런 Chunk의 리스트이다. FileView는 파일에 접근하고 Document를 열 수 있는 파일 브라우저이다. StashList는 단순한 파일이나 텍스트 등을 임시로 저장하고 꺼내쓰는 컨테이너이다.\n\n### 3.7.2. 사용자 인터페이스 상세\n\n![interface](./interface.png)\n\n다음과 같이 컴포넌트가 배치될 것이다. 배치될 컴포넌트는 Treeview, Chunk, ChunkList, Appbar, GeneralDialogue, Drawer, ContextMenu, StashList가 있다.","number":[3],"sub_items":[],"path":"specific.md","source_path":"specific.md","parent_names":[]}},{"Chapter":{"name":"Supporting information","content":"# 추가 이력 (Supporting Information)\n\n## 4.1. 부록(Appendixes)\n\n내용 없음.\n\n## 4.2. 개발 환경(Development Environment)\n\n프론트엔드는 [Vite](https://vitejs-kr.github.io/)로 개발한다. 그리고 개발 언어로는 typescript를 사용한다. 그리고 react를 사용한다.\n\n백엔드는 Deno를 사용한다.\n\n## 4.3. 일정표(Schedule)\n\n<%\nconst getIssueByNumber = (n) => it.issues.filter(x=> x.number === n)[0];\n\nconst trId= (n)=>{\n const title = getIssueByNumber(n).title;\nreturn `(#${n}) ${title}`.replaceAll(/[^A-Za-z\\s0-9]/gi,\"\").toLocaleLowerCase().replaceAll(\" \",\"-\");\n}\nconst inTable = (arr) => {\n return arr.map(x=> `[#${x}](./specific.md#${trId(x)})`).join(', ')\n}\n%>\n<%\nconst timeTable = [\n [1,2,3,5,14,15,27],\n [4,6,7,8,11],\n [9,10,12,22,23],\n [13,16,17,19,20,21,30],\n [18,,24,25],\n [28,29]\n]\nconst Weeks = [\n'4.24~4.30', \n'5.1~5.7', \n'5.8~5.14', \n'5.15~5.21', \n'5.22~5.28', \n'5.29~6.4'\n]\n%>\n\n|주차|구현 기능|\n|----|--------|\n|<%= Weeks[0]%>|<%= inTable(timeTable[0]) %>|\n|<%= Weeks[1]%>|<%= inTable(timeTable[1]) %>|\n|<%= Weeks[2]%>|<%= inTable(timeTable[2]) %>|\n|<%= Weeks[3]%>|<%= inTable(timeTable[3]) %>|\n|<%= Weeks[4]%>|<%= inTable(timeTable[4]) %>|\n|<%= Weeks[5]%>|<%= inTable(timeTable[5]) %>|\n\n<% for(let weekIndex = 0; weekIndex < Weeks.length; weekIndex++) {%>\n### 주차 <%= Weeks[weekIndex]%>\n\n<%~ timeTable[weekIndex].map(n => {\n return `- [(#${n}) ${getIssueByNumber(n).title}](specific.md#${trId(n)})\\n`\n }).join('')\n%>\n\n<%}%>\n","number":[4],"sub_items":[],"path":"support.md","source_path":"support.md","parent_names":[]}},{"Chapter":{"name":"Architecture","content":"# 5. 설계\n\n## 5.1 UML\n\n### 5.1.1 Server Side UML\n\n```mermaid\nclassDiagram\nclass PermissionDescriptor {\n +canRead(path: string): boolean\n +canWrite(path: string): boolean\n +canCustom(path: string, options: any): boolean\n}\n<<Interface>> PermissionDescriptor\nPermissionDescriptor <|.. PermissionImpl\nclass PermissionImpl {\n +basePath: string\n +writable: boolean\n +canRead(path: string): boolean\n +canWrite(path: string): boolean\n +canCustom(_path: string, _options: any): boolean\n}\nSessionStore o-- UserSession\nUserSession *-- PermissionDescriptor\nclass SessionStore~T~ {\n +sessions: Record<string, T>\n +get(id: string): T\n +set(id: string, value: T): void\n +delete(id: string): void\n +saveToFile(path: string): Promise<void>\n +loadFromFile(path: string): Promise<void>\n}\nclass UserSession {\n +id: string\n +superuser: boolean\n +expiredAt: number\n +permissionSet: PermissionDescriptor\n}\n<<Interface>> UserSession\n```\n```mermaid\nclassDiagram\nRouter <|.. FileServeRouter\nclass FileServeRouter {\n +fn: Handler\n +match(path: string, _ctx: MatchContext): any\n}\nclass MethodRouterBuilber {\n +handlers: MethodRouter\n +get(handler: Handler): this\n +post(handler: Handler): this\n +put(handler: Handler): this\n +delete(handler: Handler): this\n +build(): Handler\n}\nclass ResponseBuilder {\n +status: Status\n +headers: Record<string, string>\n +body?: BodyInit\n +setStatus(status: Status): this\n +setHeader(key: string, value: string): this\n +setBody(body: BodyInit): this\n +redirect(location: string): this\n +build(): Response\n}\nclass MatchContext\n<<Interface>> MatchContext\nclass Router~T~ {\n +match(path: string, ctx: MatchContext): T\n}\n<<Interface>> Router\nRouter <|.. TreeRouter\nclass TreeRouter~T~ {\n -staticNode: Record<string, TreeRouter<T>>\n -simpleParamNode?: SimpleParamNode<T>\n -regexParamNodes: Map<string, RegexNode<T>>\n -fallbackNode?: Router<T>\n -elem?: T\n -findRouter(path: string, ctx: MatchContext): [TreeRouter<T>, string]\n +match(path: string, ctx?: MatchContext): T\n +register(path: string, elem: T): this\n +registerRouter(path: string, router: Router<T>): void\n -setOrMerge(elem: RegElem<T>): void\n -registerPath(path: string, elem: RegElem<T>): void\n -singleRoute(p: string): SingleRouteOutput<T>\n}\nMethodRouter <-- MethodRouterBuilber: Create\nRouter <|.. MethodRouter\nRouter <|.. FsRouter\nResponse <-- ResponseBuilder: Create\n```\n\n```mermaid\nclassDiagram\nclass ChunkMethodAction {\n +action: (doc: ActiveDocumentObject) => ChunkMethodHistory\n +checkConflict: (m: ChunkMethodHistory) => boolean\n +trySolveConflict: (m: ChunkMethodHistory) => boolean\n}\n<<Interface>> ChunkMethodAction\nChunkMethodAction <|.. ChunkCreateAction\nclass ChunkCreateAction {\n +params: ChunkCreateMethod\n +action(doc: ActiveDocumentObject): ChunkCreateHistory\n +checkConflict(m: ChunkMethodHistory): boolean\n +trySolveConflict(m: ChunkMethodHistory): boolean\n}\nChunkMethodAction <|.. ChunkDeleteAction\nclass ChunkDeleteAction {\n +params: ChunkDeleteMethod\n +action(doc: ActiveDocumentObject): ChunkRemoveHistory\n +checkConflict(_m: ChunkMethodHistory): boolean\n +trySolveConflict(_m: ChunkMethodHistory): boolean\n}\nChunkMethodAction <|.. ChunkModifyAction\nclass ChunkModifyAction {\n +params: ChunkModifyMethod\n +action(doc: ActiveDocumentObject): ChunkModifyHistory\n +checkConflict(m: ChunkMethodHistory): boolean\n +trySolveConflict(_m: ChunkMethodHistory): boolean\n}\nChunkMethodAction <|.. ChunkMoveAction\nclass ChunkMoveAction {\n +params: ChunkMoveMethod\n +action(doc: ActiveDocumentObject): ChunkMoveHistory\n +checkConflict(_m: ChunkMethodHistory): boolean\n +trySolveConflict(_m: ChunkMethodHistory): boolean\n}\n```\n```mermaid\nclassDiagram\nDocumentObject <|.. FileDocumentObject\nclass FileDocumentObject {\n +docPath: string\n +chunks: Chunk[]\n +tags: string[]\n +updatedAt: number\n +tagsUpdatedAt: number\n +open(): Promise<void>\n +parse(content: unknown[]): void\n +save(): Promise<void>\n}\nclass Participant {\n +id: string\n +user: UserSession\n +send(data: string): void\n +addEventListener(type: T, listener: (this: WebSocket, event: WebSocketEventMap[T]) => void): void\n +removeEventListener(type: T, listener: (this: WebSocket, event: WebSocketEventMap[T]) => void): void\n +close(): void\n}\n<<Interface>> Participant\nParticipant <|.. Connection\nclass Connection {\n +id: string\n +user: UserSession\n +socket: WebSocket\n +send(data: string): void\n +addEventListener(type: T, listener: (this: WebSocket, event: WebSocketEventMap[T]) => void): void\n +removeEventListener(type: T, listener: (this: WebSocket, event: WebSocketEventMap[T]) => void): void\n +close(code?: number, reason?: string): void\n}\nclass ParticipantList {\n +connections: Map<string, Participant>\n +add(id: string, p: Participant): void\n +get(id: string): any\n +remove(id: string): void\n +unicast(id: string, message: string): void\n +broadcast(message: string): void\n}\nFileDocumentObject <|-- ActiveDocumentObject\nclass ActiveDocumentObject {\n +conns: Set<Participant>\n +history: DocHistory[]\n +maxHistory: number\n +join(conn: Participant): void\n +leave(conn: Participant): void\n +updateDocHistory(method: ChunkMethodHistory): void\n +broadcastMethod(method: ChunkMethod, updatedAt: number, exclude?: Participant): void\n}\nclass DocumentStore {\n +documents: Inline\n +open(conn: Participant, docPath: string): Promise<ActiveDocumentObject>\n +close(conn: Participant, docPath: string): void\n +closeAll(conn: Participant): void\n}\nParticipantList o-- Participant\nDocumentStore o-- ActiveDocumentObject\n```\n```mermaid\nclassDiagram\nclass IFsWatcher{\n addEventListener(): void\n onNofity(e: FileWatchEvent): void\n}\n<<interface>> IFsWatcher\nclass FsWatcherImpl{\n onNotify(e: FileWatchEvent): void\n}\n\n```\n### 5.1.2 Client Side UML\n\n```mermaid\nclassDiagram\nError <|-- RPCErrorWrapper\nclass RPCErrorWrapper {\n +data?: unknown\n +code: RPCErrorCode\n +toJSON(): Inline\n}\nMessageEvent <|-- RPCNotificationEvent\nclass RPCNotificationEvent {\n +notification: ChunkNotification\n}\n```\n```mermaid\nclassDiagram\nclass ViewModelBase {\n +updateAsSource(path: string, updatedAt: number): void\n}\n<<Interface>> ViewModelBase\nViewModelBase <|.. IViewModel\nclass IViewModel {\n +pageView: IPageViewModel\n}\n<<Interface>> IViewModel\nIPageViewModel <|.. BlankPage\nclass BlankPage {\n +type: string\n +updateAsSource(_path: string, _updatedAt: number): void\n}\nIViewModel <|.. ViewModel\nclass ViewModel {\n +pageView: IPageViewModel\n +updateAsSource(path: string, updatedAt: number): void\n}\nViewModelBase <|.. IPageViewModel\nclass IPageViewModel {\n +type: string\n}\n<<Interface>> IPageViewModel\nIPageViewModel <|.. IDocumentViewModel\nclass IDocumentViewModel {\n +updateOnNotification(notification: ChunkNotification): void\n}\n<<Interface>> IDocumentViewModel\nclass ChunkListMutator {\n +add(i?: number | undefined, chunkContent?: ChunkContent | undefined): void\n +create(i?: number | undefined): void\n +addFromText(i: number, text: string): void\n +del(id: string): void\n +move(id: string, pos: number): void\n}\n<<Interface>> ChunkListMutator\nclass ChunkListState {\n +chunks: Chunk[]\n +cloen(): ChunkListState\n}\nclass ChunkListStateMutator\n<<Interface>> ChunkListStateMutator\nclass ChunkListHistory {\n +history: ChunkListHistoryElem[]\n +limit: number\n -applyLast(mutator: ChunkListStateMutator, updatedAt: number): void\n +current: ChunkListState\n +currentUpdatedAt: number\n +revoke(): void\n +apply(mutator: ChunkListStateMutator, updatedAt: number): boolean\n}\nclass ChunkMutator {\n +setType(t: ChunkContentType): void\n +setContent(s: string): void\n}\n<<Interface>> ChunkMutator\nIDocumentViewModel <|.. DocumentViewModel\nclass DocumentViewModel {\n +type: \"document\"\n +docPath: string\n +chunks: Chunk[]\n +history: ChunkListHistory\n +buffer: Inline\n +seq: number\n +tags: string[]\n +updatedAt: number\n +tagsUpdatedAt: number\n +updateOnNotification(notification: ChunkNotification): void\n -updateMark(updatedAt: number): void\n +apply(mutator: ChunkListStateMutator, updatedAt: number, seq: number, refresh?: boolean): void\n +updateAsSource(_path: string, _updatedAt: number): void\n +useChunks(): [Chunk[], ChunkListMutator]\n +useTags(): [string[], (tags: string[]) => Promise<void>]\n +useChunk(chunk_arg: Chunk): [Chunk, ChunkMutator]\n}\nIViewModel ..> \"1\" IPageViewModel\nViewModel ..> \"1\" IPageViewModel\nChunkListHistory ..> \"1\" ChunkListStateMutator\nChunkListHistory ..> \"1\" ChunkListState\nDocumentViewModel ..> \"1\" ChunkListHistory\nDocumentViewModel ..> \"1\" ChunkListStateMutator\nDocumentViewModel ..> \"1\" ChunkListMutator\nDocumentViewModel ..> \"1\" ChunkMutator\n```\n```mermaid\nclassDiagram\nclass ChunkListStateMutator\n<<Interface>> ChunkListStateMutator\nclass ChunkListStateAddMutator\nclass ChunkListStateDeleteMutator\nclass ChunkListStateModifyMutator\nclass ChunkListStateMoveMutator\nChunkListStateMutator <|.. ChunkListStateAddMutator\nChunkListStateMutator <|.. ChunkListStateDeleteMutator\nChunkListStateMutator <|.. ChunkListStateModifyMutator\nChunkListStateMutator <|.. ChunkListStateMoveMutator\n```\n```mermaid\nclassDiagram\nclass IRPCMessageManager {\n +opened: boolean\n +close(): void\n +sendNotification(notification: ChunkNotification): void\n +addEventListener(name: \"notification\", listener: RPCMessageMessagerEventListener): void\n +addEventListener(name: string, listener: EventListenerOrEventListenerObject): void\n +removeEventListener(name: \"notification\", listener: RPCMessageMessagerEventListener): void\n +removeEventListener(name: string, listener: EventListenerOrEventListenerObject): void\n +invokeMethod(m: RPCMessageBody): Promise<RPCResponse>\n}\n<<Interface>> IRPCMessageManager\nIRPCMessageManager <|.. RPCMessageManager\nclass RPCMessageManager {\n -callbackList: Map<number, RPCCallback>\n -curId: number\n -ws?: WebSocket | undefined\n +opened: boolean\n +open(url: string | URL, protocals?: string | undefined): Promise<void>\n +close(): void\n -genId(): number\n +genHeader(): Inline\n +send(message: RPCMethod): Promise<RPCResponse>\n +sendNotification(message: ChunkNotification): void\n +addEventListener(type: \"notification\", callback: RPCMessageMessagerEventListener): void\n +removeEventListener(type: \"notification\", callback: RPCMessageMessagerEventListener): void\n +invokeMethod(m: RPCMessageBody): Promise<RPCResponse>\n}\nclass FsDirEntry {\n +name: string\n +isDirectory: boolean\n +isFile: boolean\n +isSymlink: boolean\n}\n<<Interface>> FsDirEntry\nclass FsStatInfo {\n +isFile: boolean\n +isDirectory: boolean\n +isSymlink: boolean\n +size: number\n +mtime: Date | null\n +atime: Date | null\n +birthtime: Date | null\n}\n<<Interface>> FsStatInfo\nFsStatInfo <|.. FsGetResult\nclass FsGetResult {\n +entries?: FsDirEntry[] | undefined\n}\n<<Interface>> FsGetResult\nclass IFsEventMap {\n +modify: (this: IFsManager, event: MessageEvent<NotImplemented>) => void\n +create: (this: IFsManager, event: MessageEvent<NotImplemented>) => void\n +delete: (this: IFsManager, event: MessageEvent<NotImplemented>) => void\n}\n<<Interface>> IFsEventMap\nclass IFsManager {\n +get(path: string): Promise<Response>\n +getStat(path: string): Promise<FsGetResult>\n +upload(filePath: string, data: BodyInit): Promise<number>\n +delete(filePath: string): Promise<number>\n +mkdir(path: string): Promise<number>\n +addEventListener(name: string, listener: EventListenerOrEventListenerObject): void\n +removeEventListener(name: string, listener: EventListenerOrEventListenerObject): void\n +dispatchEvent(event: Event): boolean\n}\n<<Interface>> IFsManager\nIFsManager <|.. FsManager\nclass FsManager {\n -manager: RPCMessageManager\n -prefix: string\n +get(path: string): Promise<Response>\n +getStat(filePath: string): Promise<FsGetResult>\n +upload(filePath: string, data: BodyInit): Promise<number>\n +mkdir(filePath: string): Promise<number>\n +delete(filePath: string): Promise<number>\n}\nFsGetResult ..> \"*\" FsDirEntry\nIFsEventMap ..> \"1\" IFsManager\nIFsManager ..> \"1\" FsGetResult\nFsManager ..> \"1\" RPCMessageManager\nFsManager ..> \"1\" FsGetResult\n```\n\n## 5.2 의사코드\n\n의사코드는 다음과 같이 진행된다.\n\n### 서버 RPC 메세지 처리\n\n클라이언트는 RPC를 진행하기 위해 웹소켓을 연결합니다. 웹소켓의 주소 `/ws`에 \n도달하기 위해서 먼저 라우팅이 진행이 됩니다. 라우팅은 `TreeRouter` 클래스에서\n 진행됩니다.\n```\nInput: req Request\nOutput: Response\nfn Route(req){\n for node in HandlerNodes {\n if(req.url prefix is matching){\n node.Handler[matching](req)\n }\n }\n response with 404 Not Found\n}\n``` \n마침내 엔드포인트에 도달하게 되면 웹소켓을 얻어내고 새로운 연결을 등록합니다.\n```\nInput: req Request\nOutput: Response\nfn RPCHandleEndpoint(req){\n ws, res = upgradeWebSocket(req)\n user = getUserInfo(req.header)\n conn = new Connection(ws, user) \n registerParticipant(conn)\n res\n}\n```\n이제부터 메세지를 받을 수 있습니다.\n메세지가 오면 이 함수가 실행이 됩니다.\n```\nInput: message on send\nOutput: response\nfn Connection.handleMessage(msg: string){\n data = JSON.parse(msg)\n check format of data\n dispatch(data.method, data, this)\n}\n```\n\n디스패치가 성공적으로 이루어지면 RPC 작업를 처리하는 함수에 도착합니다.\n청크 작업을 예로 들겠습니다. 청크 작업에서는 권한을 확인하고\n명령의 충돌을 히스토리를 비교하며 다시 적용하며 해결합니다.\n그리고 요구된 작업을 처리하고 문서의 `updatedAt`을 업데이트하고\n문서 업데이트 사실을 이 문서를 보고 있던 참여자에게 전파합니다.\n```\nInput: conn Connection\nInput: m RPCChunkMethod\nOutput: response\n\nfn ChunkOperation(conn, m){\n doc = DocStore.getDocument(m.docPath);\n updatedAt = m.updatedAt;\n action = getAction(m);\n if !conn.userpermissionSet.canDo(Action) {\n response with PermissionError;\n }\n \n appliedList = doc.history.filter(x => x.updatedAt > updatedAt)\n for m in appliedList {\n if action.checkConflict(m) {\n resolvedAction = action.tryResolveConflict(m)\n if resolvedAction == Fail {\n response with ConflictError\n }\n action = resolvedAction\n }\n }\n \n res = action.act(doc);\n doc.updateHistory(res);\n \n subscribers = doc.getSubscribers();\n subscribers.broadcastNotification(m, exclude = conn);\n response with res\n}\n```\n\n문서 작업도 마찬가지로 이루어집니다.\n\n### 클라이언트의 메세지 처리 동기화\n\n클라이언트에서는 다음과 같은 일이 일어납니다.\n\n먼저 `notification`을 받습니다. 그러면 모든 `DocumentViewModel`에게 이벤트를 전달합니다.\n그리고 각각의 `DocumentViewModel`은 자기 문서에 일어난 일인지 확인하고 `ChunkListMutator`를 만들어서\n문서에 적용합니다.\n```\nInput: e RPCNotification\nInput: this document view model\n\nfn updateOnNotification(this,notification){\n { docPath, method, seq, updatedAt } = notification.params;\n if (docPath !== this.docPath) return;\n mutator = ChunkListMutatorFactory.createFrom(method);\n this.apply(mutator, updatedAt, seq);\n}\n```\n\n문서의 레디큐에 mutator를 집어넣고 `seq` 번호가 기다리는 것이면 실행하고 업데이트합니다.\n\n```\nInput: mutator ChunkListMutator\nInput: updatedAt Date\nInput: seq number\nInput: this Document View Model\n\n//readyQueue is priority queue.\nfn apply(this, mutator, updatedAt, seq){\n this.readyQueue.push({mutator, updatedAt, seq})\n\n if(this.readyQueue.lenght < some limit){\n while(!readyQueue.empty() &&\n this.readyQueue.top().seq === this.seq + 1)\n {\n mutator, updatedAt, seq = this.readyQueue.pop();\n mutator(this.chunks);\n this.seq = seq;\n this.updatedAt = updatedAt; \n }\n this.dispatch(new Event(\"chunksChange\"));\n }\n else {\n document.refresh();\n }\n}\n```\n\n### 다른 작업들\n\n```\nmodule chunk {\n type mode = Read | Write\n\n struct Chunk {\n id: string\n content: string\n type: string\n }\n\n newChunk() {\n { id = uuid()\n ; content = \"\"\n ; type = \"\"\n }\n }\n\n chunkViewer(chunk : Chunk, focusedChunk : State<string>, deleteThis : () => void) : Component {\n var mode = Read\n\n var c = new Component(\n content { value = chunk.content }\n settypebutton\n editbutton\n deletebutton\n )\n\n when mode becomes Read { chunk.content = content }\n when mode becomes Write { focusedChunk = chunk.id }\n when focusedChunk is changed { mode = Read }\n\n when editbutton is clicked { mode = (mode = Read) ? Write : Read }\n when deletebutton is clicked { deleteThis() }\n when settypebutton is clicked { chunk.type = prompt() }\n\n return c\n }\n}\n```\n\n```\nmodule search {\n searchWord(chunks, word) {\n return doc.chunks.concat_map((s) => s.matchAll(word))\n }\n\n searchWordPrompt(chunks: Chunk.chunk list) {\n var word = prompt()\n var results = searchWord(chunks, word)\n\n var c = new Component(results)\n\n when result in results is selected {\n moveto(result.location)\n close()\n }\n }\n\n when Ctrl-F is pressed { searchWordPrompt() }\n}\n```\n\n```\nmodule document {\n struct Document {\n title: string\n path: Path\n tags: string set\n chunks: chunk.Chunk array\n }\n\n documentViewer(doc: document) : Component {\n var focusedChunk = null\n\n var c = new Component(\n taglist { value: tags }\n chunklist\n )\n\n delete(id) {\n i = doc.chunks.find((c) => c.id = id)\n doc.chunks.remove(i)\n }\n\n chunklist = doc.chunks.concat_map((c, i) =>\n [ divider(i), chuknViewer(c, focusedChunk, () => delete(c.id)) ])\n\n when divider(i) clicked { doc.chunks.insert(i, c) }\n when chunkViewer(c) is dropped on divider(i) { doc.chunks.move(c, i) }\n\n return c\n }\n}\n```\n\n```\nmodule filelist {\n fileList(dir : Directory, open: (File) => void) : Component {\n var c = new Component(\n filelist\n )\n\n filelist = dir.files().map((f) => button(f))\n\n when button(f) is clicked {\n open(f)\n }\n\n return c\n }\n}\n```\n\n```\nmodule settings {\n settings() : Component {\n var c = new Component(\n language = select(\"korean\", \"english\")\n theme = select(\"light\", \"dark\")\n )\n\n when language(l) is selected {\n global context.lang = l\n }\n\n when theme(t) is selected {\n global context.theme = t\n }\n\n return c\n }\n}\n\n```\nmodule frontend {\n main() : Component {\n var docv\n\n var open = (f) => {\n with doc = openfile(f) {\n docv = document.documentViewer(doc)\n }\n }\n\n var filelist = filelist.fileList(rootdir, open)\n\n var c = new Component(\n document = docv\n filelist = filelist\n )\n\n return c\n }\n}\n```\n","number":[5],"sub_items":[],"path":"architecture.md","source_path":"architecture.md","parent_names":[]}},{"Chapter":{"name":"Testing","content":"# Testing\r\n\r\n## 유닛 테스트\r\n\r\n유닛 테스트로 69.6%의 Line Coverage와 73.4%의 Function Coverage를 달성했다.\r\n다음과 같은 로그가 있다.\r\n\r\n```\r\nrunning 2 tests from ./src/auth/permission.test.ts\r\npermission.test ... ok (8ms)\r\npermission empty ... ok (16ms)\r\nrunning 4 tests from ./src/auth/session.test.ts\r\nSession ...\r\n set ... ok (9ms)\r\n delete ... ok (16ms)\r\nok (42ms)\r\nLogin Handler ...\r\n login with invalid format ... ok (15ms)\r\n login with invalid password ... ok (16ms)\r\n login ... ok (16ms)\r\n logout with no session ... ok (16ms)\r\n logout ... ok (16ms)\r\nok (96ms)\r\ngetSession ... ok (16ms)\r\ngetSession with invalid cookie ... ok (16ms)\r\nrunning 1 test from ./src/auth/user.test.ts\r\nuser.createAdminUser ... ok (15ms)\r\nrunning 4 tests from ./src/document/filedoc.test.ts\r\nreadDocFile ... ok (19ms)\r\nreadDocFile: not found ... ok (16ms)\r\nreadDocFile: invalid json ... ok (16ms)\r\nsaveDocFile ... ok (15ms)\r\nrunning 3 tests from ./src/router/methodHandle.test.ts\r\nmethodHandle: basic methods ... ok (8ms)\r\nmethodHandle: not found ... ok (16ms)\r\nmethodHandle: options ... ok (16ms)\r\nrunning 8 tests from ./src/router/route.test.ts\r\nroute: basic route ... ok (10ms)\r\nroute: double slash route ... ok (16ms)\r\nroute: double match ... ok (16ms)\r\nroute: test context ... ok (16ms)\r\nroute: test regex ... ok (16ms)\r\nroute: test not found ... ok (16ms)\r\nroute: encode_route ... ok (2ms)\r\nroute: router in router ... ok (13ms)\r\nrunning 4 tests from ./src/rpc/chunk.test.ts\r\nbasic chunk operation ...\r\n create chunk ... ok (19ms)\r\n delete chunk ... ok (15ms)\r\n modify chunk ... ok (15ms)\r\n move chunk ... ok (15ms)\r\n invalid chunk operation ... ok (17ms)\r\nok (98ms)\r\ntest chunk notification operation ... ok (15ms)\r\ntest chunk conflict ... ok (16ms)\r\ntest chunk conflict resolve with history ... ok (32ms)\r\nrunning 2 tests from ./src/rpc/doc.test.ts\r\nhandleDocumentMethod ... ok (4ms)\r\nhandleTagMethod ...\r\n setTag ... ok (13ms)\r\n getTag ... ok (15ms)\r\n conflict ... ok (15ms)\r\nok (61ms)\r\nrunning 3 tests from ./src/rpc/share.test.ts\r\nhandleShareGetInfo ... ok (18ms)\r\nhandleShareDocMethod ... ok (15ms)\r\nhandleShareMethod with no existing share token ... ok (16ms)\r\nrunning 1 test from ./src/server.test.ts\r\nserver rpc test ... ok (1s)\r\nrunning 3 tests from ./src/setting.test.ts\r\nsetting: basic ... ok (35ms)\r\nsetting: default value ... ok (7ms)\r\nsetting: defered register ... ok (16ms)\r\ntest result: ok. 35 passed (15 steps); 0 failed; 0 ignored; 0 measured; 0 filtered out (2s)\r\n```\r\n\r\n\r\n## 기능 테스트\r\n\r\n### Chunk\r\n\r\n<table>\r\n<thead>\r\n<tr>\r\n<th>ID</th>\r\n<th>Content</th>\r\n<th>Procedure</th>\r\n<th>Test Data</th>\r\n<th>P/F</th>\r\n</tr>\r\n</thead>\r\n<tbody>\r\n<tr>\r\n<td>1</td>\r\n<td>Focus/Unfocus</td>\r\n<td>1. 청크를 클릭한다.</td>\r\n<td></td>\r\n<td>P</td>\r\n</tr>\r\n<tr>\r\n<td>2</td>\r\n<td>remove</td>\r\n<td>1. 청크를 삭제하는 버튼을 클릭한다.</td>\r\n<td></td>\r\n<td>P</td>\r\n</tr>\r\n<tr>\r\n<td>3-1</td>\r\n<td>render - markdown</td>\r\n<td>1. 마크다운 청크 렌더링을 확인한다.</td>\r\n<td> # 제목 </td>\r\n<td>P</td>\r\n</tr>\r\n<tr>\r\n<td>3-2</td>\r\n<td>render - latex</td>\r\n<td>1. LaTex 청크 렌더링을 확인한다.</td>\r\n<td> sum^n_{n=0}n = \\frac{n(n+1)}2$$ </td>\r\n<td>P</td>\r\n</tr>\r\n<tr>\r\n<td>3-3</td>\r\n<td>render - link</td>\r\n<td>1. Image 청크 렌더링을 확인한다.</td>\r\n<td>http://picsum.photos</td>\r\n<td>P</td>\r\n</tr>\r\n<tr>\r\n<td>4</td>\r\n<td>previews</td>\r\n<td>1. Katex 청크의 미리보기를 본다.</td>\r\n<td> sum^n_{n=0}n = \\frac{n(n+1)}2$$ </td>\r\n<td>F</td>\r\n</tr>\r\n<tr>\r\n<td>10</td>\r\n<td>autocomplete</td>\r\n<td>1. <kbd>Ctrl+Space</kbd>를 눌러 자동완성을 시도한다.</td>\r\n<td></td>\r\n<td>F</td>\r\n</tr>\r\n<tr>\r\n<td>11</td>\r\n<td>swap positions</td>\r\n<td>1. 청크의 위치를 바꾼다.</td>\r\n<td></td>\r\n<td>P</td>\r\n</tr>\r\n<tr>\r\n<td>27-1</td>\r\n<td>edit</td>\r\n<td>1. 청크를 수정한다.</td>\r\n<td></td>\r\n<td>P</td>\r\n</tr>\r\n<tr>\r\n<td>27-2</td>\r\n<td>edit chunk conflict</td>\r\n<td>1. 청크를 수정모드에 들어간다.</td>\r\n<td></td>\r\n<td>F</td>\r\n</tr>\r\n</tbody>\r\n</table>\r\n\r\n### Document\r\n\r\n<table>\r\n<thead>\r\n<tr>\r\n<th>ID</th>\r\n<th>Content</th>\r\n<th>Procedure</th>\r\n<th>Test Data</th>\r\n<th>P/F</th>\r\n</tr>\r\n</thead>\r\n<tbody>\r\n<tr>\r\n<td>5</td>\r\n<td>view Chunk</td>\r\n<td>1. 문서를 열어 청크가 렌더링되는지 본다.</td>\r\n<td>test.syd</td>\r\n<td>P</td>\r\n</tr>\r\n<tr>\r\n<td>7</td>\r\n<td>add/delete tag</td>\r\n<td>1. 문서에 태그를 추가한다.<br>2. 문서에 태그를 삭제한다.</td>\r\n<td>A</td>\r\n<td>P</td>\r\n</tr>\r\n<tr>\r\n<td>8</td>\r\n<td>Drag And Drop Upload,</td>\r\n<td>1. 텍스트를 드래그한다.</td>\r\n<td></td>\r\n<td>P</td>\r\n</tr>\r\n</tbody>\r\n</table>\r\n\r\n### File\r\n\r\n<table>\r\n<thead>\r\n<tr>\r\n<th>ID</th>\r\n<th>Content</th>\r\n<th>Procedure</th>\r\n<th>Test Data</th>\r\n<th>P/F</th>\r\n</tr>\r\n</thead>\r\n<tbody>\r\n<tr>\r\n<td>14</td>\r\n<td>create/delete/rename file</td>\r\n<td>1. 파일을 만든다.</td>\r\n<td>test.txt</td>\r\n<td>P</td>\r\n</tr>\r\n<tr>\r\n<td>15</td>\r\n<td>upload/download files</td>\r\n<td>1. 파일을 업로드한다.</td>\r\n<td>test.txt</td>\r\n<td>P</td>\r\n</tr>\r\n<tr>\r\n<td>18</td>\r\n<td>export document</td>\r\n<td>1. export 버튼을 누른다.</td>\r\n<td></td>\r\n<td>F</td>\r\n</tr>\r\n</tbody>\r\n</table>\r\n\r\n### Search\r\n\r\n<table>\r\n<thead>\r\n<tr>\r\n<th>ID</th>\r\n<th>Content</th>\r\n<th>Procedure</th>\r\n<th>Test Data</th>\r\n<th>P/F</th>\r\n</tr>\r\n</thead>\r\n<tbody>\r\n<tr>\r\n<td>16</td>\r\n<td>Document Search</td>\r\n<td>1. 검색버튼을 눌러 검색을 한다.</td>\r\n<td>chunk</td>\r\n<td>F</td>\r\n</tr>\r\n</tbody>\r\n</table>\r\n\r\n### Stash\r\n\r\n<table>\r\n<thead>\r\n<tr>\r\n<th>ID</th>\r\n<th>Content</th>\r\n<th>Procedure</th>\r\n<th>Test Data</th>\r\n<th>P/F</th>\r\n</tr>\r\n</thead>\r\n<tbody>\r\n<tr>\r\n<td>17</td>\r\n<td>render</td>\r\n<td>1. 스태시가 그려지는지 확인한다</td>\r\n<td></td>\r\n<td>P</td>\r\n</tr>\r\n<tr>\r\n<td>19</td>\r\n<td>add</td>\r\n<td>1. 청크를 추가한다</td>\r\n<td></td>\r\n<td>P</td>\r\n</tr>\r\n<tr>\r\n<td>20</td>\r\n<td>remove</td>\r\n<td>1. 청크를 삭제한다</td>\r\n<td></td>\r\n<td>P</td>\r\n</tr>\r\n<tr>\r\n<td>21</td>\r\n<td>Drag and Drop to Document</td>\r\n<td>1. 청크로부터 문서로 청크를 옮긴다.</td>\r\n<td></td>\r\n<td>P</td>\r\n</tr>\r\n</tbody>\r\n</table>\r\n\r\n### Management\r\n\r\n<table>\r\n<thead>\r\n<tr>\r\n<th>ID</th>\r\n<th>Content</th>\r\n<th>Procedure</th>\r\n<th>Test Data</th>\r\n<th>P/F</th>\r\n</tr>\r\n</thead>\r\n<tbody>\r\n<tr>\r\n<td>22</td>\r\n<td>Login</td>\r\n<td>1. 비밀번호를 입력한다.</td>\r\n<td>admin</td>\r\n<td>F</td>\r\n</tr>\r\n<tr>\r\n<td>24</td>\r\n<td>Localization</td>\r\n<td>1. 다른언어를 지원하는지 언어를 바꿔 확인한다</td>\r\n<td></td>\r\n<td>F</td>\r\n</tr>\r\n</tbody>\r\n</table>\r\n","number":[6],"sub_items":[],"path":"testing.md","source_path":"testing.md","parent_names":[]}}],"__non_exhaustive":null}]