diff --git a/.agents/skills/bootcamp-ui-design/SKILL.md b/.agents/skills/bootcamp-ui-design/SKILL.md new file mode 100644 index 00000000000..81782642ab8 --- /dev/null +++ b/.agents/skills/bootcamp-ui-design/SKILL.md @@ -0,0 +1,46 @@ +--- +name: bootcamp-ui-design +description: Design, implement, or review user-facing Rails/Slim screens in fjordllc/bootcamp so spacing, layout, forms, cards, actions, responsive behavior, and visual hierarchy match the existing Bootcamp UI. Use for new pages, UI changes, CSS changes, design reviews, screenshot comparisons, or requests to make a page feel consistent with Bootcamp. +--- + +# Bootcamp UI Design + +Use existing Bootcamp structures as the design system. Prefer composition of established classes over new page-specific spacing rules. + +## Workflow + +1. Identify the page type: detail, list, form, result/status, or empty state. +2. Read [references/patterns.md](references/patterns.md) and inspect the listed source examples for that page type. +3. Search for existing components and classes before editing CSS: + - `rg "" app/views app/assets/stylesheets` + - `rg -l "" app/views` +4. Implement the smallest structural change that reuses existing primitives. +5. Render realistic fixture data at desktop and mobile widths. +6. Capture screenshots and compare them with the selected reference pages. +7. Iterate until spacing, hierarchy, width, and actions feel consistent. +8. Run focused tests, Slim-Lint, Stylelint when CSS changes, and `git diff --check`. + +## Required UI Checks + +- Use `.page-main`, `.page-main-header`, `.page-body`, and `.container` consistently with comparable pages. +- Use `.page-body__rows` when direct page sections need the standard vertical gap. +- Put card content inside `.card-body > .card-body__inner`; `.card-body` alone does not provide standard padding. +- Use `.form__items`, `.form-item`, and `.form-actions` for form rhythm and action placement. +- Use existing container widths (`.is-md`, `.is-lg`, `.is-xl`) based on comparable pages. +- Keep primary, cancel, and destructive actions in their established `form-actions__item` roles. +- Verify long Japanese text, empty states, validation errors, and realistic row counts. +- Verify at 1440px desktop and 390px mobile widths. Do not approve from desktop alone. +- Avoid arbitrary margin or padding values when an existing structural class provides the spacing. +- If new CSS is necessary, explain which existing primitive was insufficient and use project variables. + +## Visual Review Output + +Report: + +- reference pages used; +- reused layout and component classes; +- desktop and mobile screenshots captured; +- remaining intentional differences; +- focused tests and linters run. + +Do not describe a screen as Bootcamp-like without rendering and inspecting it. diff --git a/.agents/skills/bootcamp-ui-design/agents/openai.yaml b/.agents/skills/bootcamp-ui-design/agents/openai.yaml new file mode 100644 index 00000000000..3c874eb36fd --- /dev/null +++ b/.agents/skills/bootcamp-ui-design/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Bootcamp UI Design" + short_description: "Bootcampらしい画面設計と視覚検証を一貫して支援" + default_prompt: "Use $bootcamp-ui-design to implement this UI consistently with existing Bootcamp pages." diff --git a/.agents/skills/bootcamp-ui-design/references/patterns.md b/.agents/skills/bootcamp-ui-design/references/patterns.md new file mode 100644 index 00000000000..b1ba213f91f --- /dev/null +++ b/.agents/skills/bootcamp-ui-design/references/patterns.md @@ -0,0 +1,72 @@ +# Bootcamp UI Patterns + +Inspect source files instead of copying this document as markup. These are maintained examples in the repository. + +## Page Structure + +- Standard page body and responsive section spacing: + - `app/assets/stylesheets/application/blocks/page/_page-body.css` + - `.page-body`, `.page-body__rows`, `.page-body__columns` +- Page headers and actions: + - `app/views/mentor/practices/practice_quiz/edit.html.slim` + - `app/views/mentor/practices/practice_quiz/questions/edit.html.slim` + +## Forms + +- Standard mentor form rhythm and actions: + - `app/views/mentor/categories/_form.html.slim` + - `app/views/mentor/coding_tests/_form.html.slim` + - `app/assets/stylesheets/shared/blocks/form/_form.css` + - `app/assets/stylesheets/shared/blocks/form/_form-item.css` + - `app/assets/stylesheets/shared/blocks/form/_form-actions.css` +- Use `.form__items` for a group, `.form-item` between fields, and `.form-actions` after fields. +- Do not add margins to individual labels or buttons until the form primitives have been used correctly. + +## Cards + +- Card structure: + +```slim +.a-card + header.card-header + h2.card-header__title Title + hr.a-border-tint + .card-body + .card-body__inner + / content +``` + +- Relevant styles: + - `app/assets/stylesheets/shared/blocks/card/_card-header.css` + - `app/assets/stylesheets/shared/blocks/card/_card-body.css` +- `.card-body__inner` owns the standard responsive padding. Omitting it makes content touch card edges. + +## Tables and Empty States + +- Admin tables are standalone because `.admin-table` owns its borders, background, and corner treatment. Do not wrap an admin table in `.a-card`. +- Use a preceding `header.page-body-header` when the table needs a local title or count. +- Reference implementations: + - `app/views/mentor/categories/index.html.slim` + - `app/views/mentor/coding_tests/index.html.slim` + - `app/views/mentor/practices/practice_quiz/edit.html.slim` +- Empty state: + - `.o-empty-message` in the same file. + +## Result and Status Screens + +- Status plus repeated result cards: + - `app/views/practices/practice_quiz/show.html.slim` + - `app/assets/stylesheets/application/blocks/practice-quiz/_practice-quiz.css` +- Keep status messaging visually separate from question/result cards. + +## Screenshot Review + +Use realistic development fixtures. At minimum capture: + +- desktop: 1440 x 1000; +- mobile: 390 x 844; +- form before submission; +- result or state after submission when applicable; +- management/edit screen for mentor-facing features. + +Compare section padding, gaps between sibling sections, content width, heading hierarchy, button placement, table density, and mobile stacking. diff --git a/.cloudbuild/deploy-cloud-run b/.cloudbuild/deploy-cloud-run index 0661c5be67f..62233cd65e7 100755 --- a/.cloudbuild/deploy-cloud-run +++ b/.cloudbuild/deploy-cloud-run @@ -48,12 +48,12 @@ fi case "$environment" in production) export _RAILS_MAX_THREADS=5 - export _PJORD_LLM_MODEL=claude-sonnet-4-6 + export _PJORD_LLM_MODEL=claude-sonnet-5 cloud_run_args+=(--concurrency 5) ;; staging) export _MISSION_CONTROL_USERNAME="${_MISSION_CONTROL_USERNAME:-fjord}" - export _PJORD_LLM_MODEL=claude-sonnet-4-6 + export _PJORD_LLM_MODEL=claude-sonnet-5 ;; review) export _DISCORD_NOTICE_WEBHOOK_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 829c7d951f8..0411210a6fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -222,10 +222,9 @@ jobs: uses: actions/checkout@v4 - name: Generate flakewatch HTML - # komagata/flakewatch v0.6.31 - uses: komagata/flakewatch@e734ba749bb1f8de2b9ae8a35b2cb02d63bbbf76 + # komagata/flakewatch v0.6.34 + uses: komagata/flakewatch@v0.6.34 with: junit-artifact-pattern: junit-xml-* source-root: ${{ github.workspace }} history-branch: flakewatch-data - history-write: auto diff --git a/.gitignore b/.gitignore index dd79f1bb921..08a1e1c4a37 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,7 @@ yarn-debug.log* .yarn-integrity .claude .DS_Store -.agents +.agents/* +!.agents/skills/ +.agents/skills/* +!.agents/skills/bootcamp-ui-design/ diff --git a/Dockerfile b/Dockerfile index 029636e6977..b93b0499a3e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,6 +60,7 @@ RUN apt-get update -qq && apt-get install -y --no-install-recommends \ python3 \ git \ ca-certificates \ + ffmpeg \ libvips && \ rm -rf /var/lib/apt/lists/* diff --git a/Dockerfile.dev b/Dockerfile.dev index 267179af413..bc23cc884dc 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -18,6 +18,7 @@ RUN apt-get update -qq && apt-get install -y --no-install-recommends \ libvips-dev \ postgresql-client \ tzdata \ + ffmpeg \ vim && \ rm -rf /var/lib/apt/lists/* diff --git a/app/agents/pjord/product_review_agent.rb b/app/agents/pjord/product_review_agent.rb index 51b6d48aab2..522c4e56a7a 100644 --- a/app/agents/pjord/product_review_agent.rb +++ b/app/agents/pjord/product_review_agent.rb @@ -10,11 +10,27 @@ class Pjord::ProductReviewAgent < Pjord::Agent class << self def review(product) - extract_public_response_body(new.ask(message(product)).content).presence + review_result(product)[:body] + end + + def review_result(product) + content = new.ask(message(product)).content + { + body: extract_public_response_body(content).presence, + auto_check: auto_check?(content) + } end private + def auto_check?(content) + return false unless content.respond_to?(:to_h) + + values = content.to_h + value = values.key?(:auto_check) ? values[:auto_check] : values['auto_check'] + ActiveModel::Type::Boolean.new.cast(value) || false + end + def message(product) user_course_practice = UserCoursePractice.new(product.user) <<~TEXT @@ -31,6 +47,7 @@ def message(product) ## プラクティス - タイトル: #{product.practice.title} + - ピヨルドによる提出物OK: #{product.practice.pjord_auto_check? ? '許可されています' : '許可されていません'} - ゴール: #{truncate_for_prompt(product.practice.goal)} - 説明: diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 7a041441082..8fa73e915fc 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -70,6 +70,7 @@ @import "./application/blocks/practice/_practice-first-actions.css"; @import "./application/blocks/practice/_practice-status-buttons.css"; @import "./application/blocks/practice/_sticky-message.css"; +@import "./application/blocks/practice-quiz/_practice-quiz.css"; @import "./application/blocks/question/_answer-badge.css"; @import "./application/blocks/reaction/_reaction.css"; @import "./application/blocks/report/_learning-times.css"; diff --git a/app/assets/stylesheets/application/blocks/practice-quiz/_practice-quiz.css b/app/assets/stylesheets/application/blocks/practice-quiz/_practice-quiz.css new file mode 100644 index 00000000000..8837d3d3bef --- /dev/null +++ b/app/assets/stylesheets/application/blocks/practice-quiz/_practice-quiz.css @@ -0,0 +1,149 @@ +.practice-quiz-status { + display: flex; + gap: 1rem; + align-items: flex-start; + padding: 1.25rem 1.5rem; + margin-bottom: 1.5rem; + border: solid 1px var(--border-tint); + border-radius: 0.5rem; + background-color: var(--base); +} + +.practice-quiz-status.is-passed { + border-color: var(--success-tint); + background-color: var(--success-tint); +} + +.practice-quiz-status.is-waiting { + border-color: var(--warning-tint); + background-color: var(--warning-tint); +} + +.practice-quiz-status.is-info { + border-color: var(--info-tint); + background-color: var(--info-tint); +} + +.practice-quiz-status__icon { + font-size: 1.75rem; + line-height: 1; + flex: 0 0 auto; +} + +.practice-quiz-status.is-passed .practice-quiz-status__icon { + color: var(--success); +} + +.practice-quiz-status.is-waiting .practice-quiz-status__icon { + color: var(--warning); +} + +.practice-quiz-status.is-info .practice-quiz-status__icon { + color: var(--info); +} + +.practice-quiz-status__title { + font-size: 1.125rem; + font-weight: bold; + margin: 0 0 0.25rem; +} + +.practice-quiz-status__text { + font-size: 0.875rem; + line-height: 1.6; + color: var(--default-text); + margin: 0; +} + +.practice-quiz-result, +.practice-quiz-form { + display: flex; + flex-direction: column; + gap: 1.25rem; + margin: 0; + padding: 0; + list-style: none; +} + +.practice-quiz-result__item .card-header, +.practice-quiz-form__item .card-header { + display: flex; + align-items: baseline; + gap: 0.75rem; +} + +.practice-quiz-question__type { + font-size: 0.75rem; + line-height: 1.4; + color: var(--semi-muted-text); + border: solid 1px var(--border); + border-radius: 1rem; + padding: 0.0625rem 0.625rem; + white-space: nowrap; +} + +.practice-quiz-question__body { + margin-bottom: 1rem; +} + +.practice-quiz-choices { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; +} + +.practice-quiz-choices__item { + display: flex; + align-items: center; + gap: 0.625rem; + font-size: 0.875rem; + line-height: 1.5; + padding: 0.625rem 0.875rem; + border: solid 1px var(--border-tint); + border-radius: 0.25rem; + color: var(--semi-muted-text); +} + +.practice-quiz-choices__item.is-correct { + border-color: var(--success-tint); + background-color: var(--success-tint); + color: var(--default-text); + font-weight: bold; +} + +.practice-quiz-choices__icon { + flex: 0 0 auto; + color: var(--muted-text); +} + +.practice-quiz-choices__item.is-correct .practice-quiz-choices__icon { + color: var(--success); +} + +.practice-quiz-choices__label { + flex: 1 1 auto; +} + +.practice-quiz-explanation { + margin-top: 1rem; + padding: 1rem 1.25rem; + border-radius: 0.25rem; + background-color: var(--background-tint); +} + +.practice-quiz-explanation__label { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + font-weight: bold; + color: var(--semi-muted-text); + margin-bottom: 0.5rem; +} + +.practice-quiz-explanation__label i { + color: var(--warning); +} diff --git a/app/assets/stylesheets/application/blocks/practice/_category-practices-item.css b/app/assets/stylesheets/application/blocks/practice/_category-practices-item.css index 6a44e99854c..fe8a6d024fd 100644 --- a/app/assets/stylesheets/application/blocks/practice/_category-practices-item.css +++ b/app/assets/stylesheets/application/blocks/practice/_category-practices-item.css @@ -151,21 +151,3 @@ a.category-practices-item__title-link:hover .category-practices-item__title-link-label { text-decoration: underline; } - -.category-practices-item__learning-time { - font-size: .75rem; - line-height: 1.4; - color: var(--muted-text); -} - -@media (width >= 48em) { - .category-practices-item__learning-time { - margin-top: -.25rem; - } -} - -@media (width <= 47.9375em) { - .category-practices-item__learning-time { - margin-top: .5rem; - } -} diff --git a/app/assets/stylesheets/application/blocks/practice/_practice-status-buttons.css b/app/assets/stylesheets/application/blocks/practice/_practice-status-buttons.css index 6f63ed839ab..91bc51aea23 100644 --- a/app/assets/stylesheets/application/blocks/practice/_practice-status-buttons.css +++ b/app/assets/stylesheets/application/blocks/practice/_practice-status-buttons.css @@ -120,3 +120,23 @@ color: var(--muted-text); margin-top: .5em; } + +.practice-status-buttons__grant-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + max-width: 16rem; +} + +.practice-status-buttons__grant-guidance { + display: flex; + flex-direction: column; + align-items: center; + gap: .75rem; +} + +.practice-status-buttons__grant-note { + margin-top: 0; + text-align: center; +} diff --git a/app/components/learnings/learning_component.html.slim b/app/components/learnings/learning_component.html.slim index b85985f2794..ec6baf441b9 100644 --- a/app/components/learnings/learning_component.html.slim +++ b/app/components/learnings/learning_component.html.slim @@ -1,17 +1,25 @@ -.card-main-actions - ul.card-main-actions__items - - if submission? - li.card-main-actions__item - = link_to product_link, class: 'a-button is-sm is-primary is-block test-product' do - i.fa-solid.fa-file - | #{product_label} - - if @practice.completed?(@current_user) - li.card-main-actions__item - button.a-button.is-sm.is-secondary.is-block.is-disabled.test-completed - i.fa-solid.fa-check - | 修了しています - - else - li.card-main-actions__item - = button_tag type: 'button', class: 'a-button is-sm is-warning is-block', id: 'js-complete', data: { practice_id: @practice.id } do - i.fa-solid.fa-check - | 修了 +- if @practice.guide_to_grant_course?(@current_user) + .practice-status-buttons + .practice-status-buttons__grant-guidance + = link_to practice_path(@practice.copied_practices.first || @practice), class: 'practice-status-buttons__grant-link a-button is-sm is-secondary' do + | 給付金コースへ移動する + .practice-status-buttons__note.practice-status-buttons__grant-note + | 提出・修了は、給付金コースのプラクティス側で行ってください。 +- else + .card-main-actions + ul.card-main-actions__items + - if @practice.submission? + li.card-main-actions__item + = link_to product_link, class: 'a-button is-sm is-primary is-block test-product' do + i.fa-solid.fa-file + | #{product_label} + - if @practice.completed?(@current_user) + li.card-main-actions__item + button.a-button.is-sm.is-secondary.is-block.is-disabled.test-completed + i.fa-solid.fa-check + | 修了しています + - else + li.card-main-actions__item + = button_tag type: 'button', class: 'a-button is-sm is-warning is-block', id: 'js-complete', data: { practice_id: @practice.id } do + i.fa-solid.fa-check + | 修了 diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index fd576802856..f1eb2f802f5 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -62,6 +62,10 @@ def require_login_for_api render json: { error: 'unauthorized' }, status: :unauthorized unless logged_in? end + def require_staff + render json: { message: '権限がありません。' }, status: :forbidden unless current_user&.staff? + end + def doorkeeper_unauthorized_render_options(error:) { json: doorkeeper_error_body(error) } end diff --git a/app/controllers/api/movies_controller.rb b/app/controllers/api/movies_controller.rb index 862634e0f5c..235f64eb1bb 100644 --- a/app/controllers/api/movies_controller.rb +++ b/app/controllers/api/movies_controller.rb @@ -1,12 +1,36 @@ # frozen_string_literal: true class API::MoviesController < API::BaseController + before_action -> { doorkeeper_authorize! :write }, only: %i[create direct_uploads], if: -> { doorkeeper_token.present? } before_action :set_movie, only: %i[update] + MOVIE_CONTENT_TYPES = %w[video/mp4 video/quicktime].freeze + def index; end + def create + @movie = current_user.movies.new(create_movie_params.except(:wip)) + apply_wip + update_published_at + + if @movie.save + render json: movie_json, status: :created + else + render json: { errors: @movie.errors }, status: :unprocessable_entity + end + rescue ActiveRecord::RecordNotFound + render json: { errors: { practice_ids: ['に存在しないIDが含まれています'] } }, status: :unprocessable_entity + end + + def direct_uploads + return render_bad_request('動画ファイルは mp4 または mov を指定してください。') unless movie_content_type? + + blob = ActiveStorage::Blob.create_before_direct_upload!(**direct_upload_params.to_h.symbolize_keys) + render json: direct_upload_json(blob), status: :created + end + def update - if @movie.update(movie_params) + if @movie.update(update_movie_params) head :ok else head :bad_request @@ -19,7 +43,61 @@ def set_movie @movie = Movie.find(params[:id]) end - def movie_params + def create_movie_params + params.require(:movie).permit( + :title, + :description, + :movie_data, + :thumbnail, + :tag_list, + :wip, + practice_ids: [] + ) + end + + def update_movie_params params.require(:movie).permit(:tag_list) end + + def direct_upload_params + params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, metadata: {}) + end + + def movie_content_type? + MOVIE_CONTENT_TYPES.include?(direct_upload_params[:content_type]) + end + + def direct_upload_json(blob) + blob.as_json(root: false, methods: :signed_id).merge( + direct_upload: { + url: blob.service_url_for_direct_upload, + headers: blob.service_headers_for_direct_upload + } + ) + end + + def apply_wip + return unless create_movie_params.key?(:wip) + + @movie.wip = ActiveModel::Type::Boolean.new.cast(create_movie_params[:wip]) + end + + def update_published_at + return if @movie.wip? || @movie.published_at? + + @movie.published_at = Time.current + end + + def movie_json + { + id: @movie.id, + title: @movie.title, + description: @movie.description, + user_id: @movie.user_id, + wip: @movie.wip, + published_at: @movie.published_at, + practice_ids: @movie.practice_ids, + tag_list: @movie.tag_list + } + end end diff --git a/app/controllers/api/practices/learning_controller.rb b/app/controllers/api/practices/learning_controller.rb index 6460b2ed8d0..d9c238379be 100644 --- a/app/controllers/api/practices/learning_controller.rb +++ b/app/controllers/api/practices/learning_controller.rb @@ -15,17 +15,18 @@ def show end def update - learning = Learning.find_or_initialize_by( - user_id: current_user.id, - practice_id: params[:practice_id] - ) + return render_invalid_status if requested_status.blank? - learning.status = if params[:status].nil? - :complete - else - params[:status].to_sym - end + learning = find_or_initialize_learning + + return render_incomplete_practice_quiz if learning.complete? && !practice.completable_by?(current_user) + + save_learning(learning) + end + private + + def save_learning(learning) status = learning.new_record? ? :created : :ok if learning.save @@ -37,7 +38,33 @@ def update end end - private + def render_invalid_status + render json: { error: 'status is invalid' }, status: :unprocessable_entity + end + + def render_incomplete_practice_quiz + render json: { error: '理解度テストに合格すると、このプラクティスを修了できます。' }, status: :unprocessable_entity + end + + def practice + @practice ||= Practice.find(params[:practice_id]) + end + + def find_or_initialize_learning + Learning.find_or_initialize_by( + user_id: current_user.id, + practice_id: practice.id + ).tap do |learning| + learning.status = requested_status + end + end + + def requested_status + return :complete if params[:status].nil? + return params[:status].to_sym if Learning.statuses.key?(params[:status]) + + nil + end def notify_to_chat_for_employment_counseling(learning) ChatNotifier.message( diff --git a/app/controllers/api/practices_controller.rb b/app/controllers/api/practices_controller.rb index e77820f1b19..63fb664020e 100644 --- a/app/controllers/api/practices_controller.rb +++ b/app/controllers/api/practices_controller.rb @@ -34,6 +34,6 @@ def set_practice end def practice_params - params.require(:practice).permit(:memo) + params.require(:practice).permit(:memo, :pjord_auto_check) end end diff --git a/app/controllers/api/reports/checks_controller.rb b/app/controllers/api/reports/checks_controller.rb new file mode 100644 index 00000000000..0c2359df9d7 --- /dev/null +++ b/app/controllers/api/reports/checks_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class API::Reports::ChecksController < API::BaseController + before_action :require_staff + before_action -> { doorkeeper_authorize! :write }, if: -> { doorkeeper_token.present? } + before_action -> { doorkeeper_authorize! :mentor }, if: -> { doorkeeper_token.present? } + + def create + report_ids = params.require(:report_ids).map(&:to_i).uniq + reports = Report.where(id: report_ids).select(:id, :user_id).index_by(&:id) + missing_ids = report_ids - reports.keys + return render json: { message: '日報が見つかりません。', report_ids: missing_ids }, status: :not_found if missing_ids.any? + + checked_report_ids = Check.where(checkable_type: 'Report', checkable_id: report_ids).pluck(:checkable_id) + unchecked_reports = reports.values_at(*(report_ids - checked_report_ids)) + checks = Check.transaction do + insert_checks(unchecked_reports).tap { |inserted_checks| delete_report_caches(inserted_checks, reports) } + end + + render json: { checks: checks.map { |check| check_json(check) } }, status: :created + end + + private + + def insert_checks(reports) + now = Time.current + rows = reports.map do |report| + { user_id: current_user.id, checkable_type: 'Report', checkable_id: report.id, created_at: now, updated_at: now } + end + result = Check.insert_all(rows, returning: %w[id]) # rubocop:disable Rails/SkipsModelValidations + Check.includes(:user).where(id: result.rows.flatten) + end + + def delete_report_caches(checks, reports) + checked_report_ids = checks.map(&:checkable_id) + return if checked_report_ids.empty? + + Cache.delete_unchecked_report_count + checked_report_ids.map { |report_id| reports.fetch(report_id).user_id }.uniq.each do |user_id| + Cache.delete_user_unchecked_report_count(user_id) + end + end +end diff --git a/app/controllers/concerns/api/checkable_check.rb b/app/controllers/concerns/api/checkable_check.rb index a23324fbec3..f2c0f3cb0be 100644 --- a/app/controllers/concerns/api/checkable_check.rb +++ b/app/controllers/concerns/api/checkable_check.rb @@ -33,10 +33,6 @@ def destroy private - def require_staff - render json: { message: '権限がありません。' }, status: :forbidden unless current_user&.staff? - end - def set_checkable @checkable = checkable_class.find_by(id: params[:"#{checkable_name}_id"]) render json: { message: "#{checkable_class.model_name.human}が見つかりません。" }, status: :not_found unless @checkable diff --git a/app/controllers/mentor/practices/practice_quiz/questions_controller.rb b/app/controllers/mentor/practices/practice_quiz/questions_controller.rb new file mode 100644 index 00000000000..4064617ffec --- /dev/null +++ b/app/controllers/mentor/practices/practice_quiz/questions_controller.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class Mentor::Practices::PracticeQuiz::QuestionsController < ApplicationController + before_action :require_admin_or_mentor_login + before_action :set_practice + before_action :set_practice_quiz + before_action :set_question, only: %i[edit update destroy] + + def new + @question = @practice_quiz.practice_quiz_questions.build(position: next_position) + (1..4).each do |index| + @question.practice_quiz_choices.build(position: index) + end + end + + def edit + (4 - @question.practice_quiz_choices.size).times do + @question.practice_quiz_choices.build(position: @question.practice_quiz_choices.size + 1) + end + end + + def create + @question = @practice_quiz.practice_quiz_questions.build(question_params) + if @question.save + redirect_to edit_mentor_practice_practice_quiz_path(@practice), notice: '問題を作成しました。' + else + render :new + end + end + + def update + if @question.update(question_params) + redirect_to edit_mentor_practice_practice_quiz_path(@practice), notice: '問題を更新しました。' + else + render :edit + end + end + + def destroy + if @question.destroy + redirect_to edit_mentor_practice_practice_quiz_path(@practice), notice: '問題を削除しました。' + else + redirect_to edit_mentor_practice_practice_quiz_path(@practice), alert: @question.errors.full_messages.join(', ') + end + end + + private + + def set_practice + @practice = Practice.find(params[:practice_id]) + end + + def set_practice_quiz + @practice_quiz = @practice.practice_quiz + redirect_to new_mentor_practice_practice_quiz_path(@practice) if @practice_quiz.blank? + end + + def set_question + @question = @practice_quiz.practice_quiz_questions.find(params[:id]) + end + + def next_position + @practice_quiz.practice_quiz_questions.maximum(:position).to_i + 1 + end + + def question_params + params.require(:practice_quiz_question).permit( + :question_type, + :body, + :explanation, + :position, + :published, + practice_quiz_choices_attributes: %i[id body correct position _destroy] + ) + end +end diff --git a/app/controllers/mentor/practices/practice_quiz_controller.rb b/app/controllers/mentor/practices/practice_quiz_controller.rb new file mode 100644 index 00000000000..0b6a5a6e593 --- /dev/null +++ b/app/controllers/mentor/practices/practice_quiz_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class Mentor::Practices::PracticeQuizController < ApplicationController + before_action :require_admin_or_mentor_login + before_action :set_practice + before_action :set_practice_quiz, only: %i[show edit update destroy] + + def show + redirect_to edit_mentor_practice_practice_quiz_path(@practice) + end + + def new + if @practice.practice_quiz.present? + redirect_to edit_mentor_practice_practice_quiz_path(@practice) + return + end + + @practice_quiz = @practice.build_practice_quiz + end + + def edit; end + + def create + @practice_quiz = @practice.build_practice_quiz(practice_quiz_params) + if @practice_quiz.save + redirect_to edit_mentor_practice_practice_quiz_path(@practice), notice: '理解度テストを作成しました。' + else + render :new + end + end + + def update + if @practice_quiz.update(practice_quiz_params) + redirect_to edit_mentor_practice_practice_quiz_path(@practice), notice: '理解度テストを更新しました。' + else + render :edit + end + end + + def destroy + @practice_quiz.destroy! + redirect_to mentor_practices_path, notice: '理解度テストを削除しました。' + end + + private + + def set_practice + @practice = Practice.find(params[:practice_id]) + end + + def set_practice_quiz + @practice_quiz = @practice.practice_quiz + redirect_to new_mentor_practice_practice_quiz_path(@practice) if @practice_quiz.blank? + end + + def practice_quiz_params + params.require(:practice_quiz).permit(:published) + end +end diff --git a/app/controllers/mentor/practices_controller.rb b/app/controllers/mentor/practices_controller.rb index effa529da5c..504b4014f94 100644 --- a/app/controllers/mentor/practices_controller.rb +++ b/app/controllers/mentor/practices_controller.rb @@ -11,7 +11,7 @@ def index end def new - @practice = Practice.new(pjord_review: true) + @practice = Practice.new(pjord_review: true, pjord_auto_check: false) end def edit; end @@ -56,6 +56,7 @@ def practice_params :submission, :open_product, :pjord_review, + :pjord_auto_check, :include_progress, :completion_image, :memo, diff --git a/app/controllers/practices/practice_quiz/attempts_controller.rb b/app/controllers/practices/practice_quiz/attempts_controller.rb new file mode 100644 index 00000000000..498ed76694a --- /dev/null +++ b/app/controllers/practices/practice_quiz/attempts_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Practices::PracticeQuiz::AttemptsController < ApplicationController + before_action :set_practice + before_action :set_practice_quiz + + def create + if @practice_quiz.passed_by?(current_user) + redirect_to practice_practice_quiz_path(@practice), notice: '理解度テストに合格済みです。' + return + end + + unless @practice_quiz.attemptable_by?(current_user) + redirect_to practice_practice_quiz_path(@practice), alert: retry_message + return + end + + attempt = PracticeQuizAttempt.create_with_answers!( + practice_quiz: @practice_quiz, + user: current_user, + answers: params[:answers] || {} + ) + + if attempt.passed? + redirect_to practice_practice_quiz_path(@practice), notice: '理解度テストに合格しました。' + else + redirect_to practice_practice_quiz_path(@practice), alert: '理解度テストは不合格でした。復習してから再度受験してください。' + end + end + + private + + def set_practice + @practice = Practice.find(params[:practice_id]) + end + + def set_practice_quiz + @practice_quiz = @practice.published_practice_quiz + redirect_to @practice, alert: '公開中の理解度テストはありません。' if @practice_quiz.blank? + end + + def retry_message + "次回は #{I18n.l(@practice_quiz.next_attempt_at_for(current_user), format: :long)} 以降に受験できます。" + end +end diff --git a/app/controllers/practices/practice_quiz_controller.rb b/app/controllers/practices/practice_quiz_controller.rb new file mode 100644 index 00000000000..035ed837f1f --- /dev/null +++ b/app/controllers/practices/practice_quiz_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Practices::PracticeQuizController < ApplicationController + before_action :set_practice + before_action :set_practice_quiz + + def show + @questions = @practice_quiz.published_questions.includes(:practice_quiz_choices) + @passed_attempt = @practice_quiz.passed_attempt_for(current_user) + @next_attempt_at = @practice_quiz.next_attempt_at_for(current_user) + end + + private + + def set_practice + @practice = Practice.find(params[:practice_id]) + end + + def set_practice_quiz + @practice_quiz = @practice.published_practice_quiz + redirect_to @practice, alert: '公開中の理解度テストはありません。' if @practice_quiz.blank? + end +end diff --git a/app/helpers/page_tabs/practices_helper.rb b/app/helpers/page_tabs/practices_helper.rb index e1917e2f1ac..144ee2d03d7 100644 --- a/app/helpers/page_tabs/practices_helper.rb +++ b/app/helpers/page_tabs/practices_helper.rb @@ -13,8 +13,18 @@ def practice_page_tabs(practice, active_tab:, include_source: nil) tabs << { name: '動画', link: practice_movies_path(practice), count: practice.movies.length } if movie_available? tabs << { name: '提出物', link: practice_products_path(practice) } if practice.submission tabs << { name: '模範解答', link: practice_submission_answer_path(practice) } if practice.submission_answer.present? + tabs << { name: '理解度テスト', link: practice_practice_quiz_path(practice) } if practice.practice_quiz_required? + tabs << { name: '理解度テスト管理', link: mentor_practice_quiz_path(practice) } if current_user.try(:admin_or_mentor?) tabs << { name: 'コーディングテスト', link: practice_coding_tests_path(practice) } if practice.coding_tests.present? render PageTabsComponent.new(tabs:, active_tab:) end + + def mentor_practice_quiz_path(practice) + if practice.practice_quiz + edit_mentor_practice_practice_quiz_path(practice) + else + new_mentor_practice_practice_quiz_path(practice) + end + end end end diff --git a/app/helpers/practices_helper.rb b/app/helpers/practices_helper.rb index 08219f7695c..b873a3e4b32 100644 --- a/app/helpers/practices_helper.rb +++ b/app/helpers/practices_helper.rb @@ -25,4 +25,16 @@ def practice_ogp_meta_tags(image_url) twitter: { image: image_url, url: request.url } ) end + + def difficulty_icon(minutes) + case minutes + when nil then '' + when 0 then 'データ収集中' + when ..300 then '🔥' # 5時間以下 + when ..600 then '🔥🔥' # 10時間以下 + when ..900 then '🔥🔥🔥' # 15時間以下 + when ..1200 then '🔥🔥🔥🔥' # 20時間以下 + else '🔥🔥🔥🔥🔥' # 20時間超 + end + end end diff --git a/app/interactors/copy_check.rb b/app/interactors/copy_check.rb index 9120fcc2d63..e6d6cc16e21 100644 --- a/app/interactors/copy_check.rb +++ b/app/interactors/copy_check.rb @@ -17,7 +17,7 @@ def call private def find_original_checks - context.original_checks = context.original_product.checks.to_a + context.original_checks = context.original_product.checks.order(:created_at, :id).limit(1).to_a context.message = if context.original_checks.empty? 'No checks found to copy' @@ -32,28 +32,15 @@ def copy_all_checks results = process_checks store_results(results) update_completion_message(results) - rescue ActiveRecord::RecordInvalid => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e context.fail!(error: "Failed to create check: #{e.message}") end def process_checks - copied_count = 0 - skipped_count = 0 + return { copied: 0, skipped: 1 } if Check.exists?(checkable: context.copied_product) - # Fetch all existing check user IDs for the copied product in one query - existing_user_ids = Check.where(checkable: context.copied_product) - .pluck(:user_id) - .to_set - - context.original_checks.each do |original_check| - if copy_check(original_check, existing_user_ids) - copied_count += 1 - else - skipped_count += 1 - end - end - - { copied: copied_count, skipped: skipped_count } + copy_check(context.original_checks.first) + { copied: 1, skipped: 0 } end def store_results(results) @@ -65,25 +52,14 @@ def update_completion_message(results) context.message = build_summary_message(results) end - def products_available? - context.original_product && context.copied_product - end - def build_summary_message(results) "Copied #{results[:copied]} check(s), skipped #{results[:skipped]} existing check(s)" end - def copy_check(original_check, existing_user_ids) - # Check if this user already has a check for the copied product - return false if existing_user_ids.include?(original_check.user_id) - + def copy_check(original_check) Check.create!( user: original_check.user, checkable: context.copied_product ) - - # Add the new user ID to the set to avoid duplicates in subsequent iterations - existing_user_ids.add(original_check.user_id) - true end end diff --git a/app/jobs/bulk_generate_movie_thumbnail_job.rb b/app/jobs/bulk_generate_movie_thumbnail_job.rb new file mode 100644 index 00000000000..e50ebfb84d4 --- /dev/null +++ b/app/jobs/bulk_generate_movie_thumbnail_job.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class BulkGenerateMovieThumbnailJob < ApplicationJob + def perform + Movie.where.missing(:thumbnail_attachment).find_each do |movie| + GenerateMovieThumbnailJob.perform_later(movie) + end + end +end diff --git a/app/jobs/pjord_product_review_job.rb b/app/jobs/pjord_product_review_job.rb index faefdf2e4f9..cbe46d5c11f 100644 --- a/app/jobs/pjord_product_review_job.rb +++ b/app/jobs/pjord_product_review_job.rb @@ -13,9 +13,21 @@ def perform(product_id:) pjord = Pjord.user return if pjord.nil? - response = Pjord::ProductReviewAgent.review(product) - return if response.blank? + response = Pjord::ProductReviewAgent.review_result(product) + body = response[:body] + return if body.blank? - Comment.create!(user: pjord, commentable: product, description: response) + Comment.create!(user: pjord, commentable: product, description: body) + auto_check_product(product, pjord) if product.practice.pjord_auto_check? && response[:auto_check] + end + + private + + def auto_check_product(product, pjord) + return if product.checked? + + check = Check.create!(user: pjord, checkable: product) + product.checks.reload + ActiveSupport::Notifications.instrument('check.create', check:) end end diff --git a/app/models/buzz.rb b/app/models/buzz.rb index f8833b7de16..23568eaebba 100644 --- a/app/models/buzz.rb +++ b/app/models/buzz.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class Buzz < ApplicationRecord + ROUTABLE_YEAR_RANGE = 1000..9999 + ROUTABLE_PUBLISHED_AT_RANGE = Date.new(ROUTABLE_YEAR_RANGE.begin, 1, 1)..Date.new(ROUTABLE_YEAR_RANGE.end, 12, 31) + validates :title, presence: true validates :published_at, presence: true validates :url, presence: true, uniqueness: { message: 'はすでに登録されています' } @@ -53,11 +56,11 @@ def for_year(year) end def latest_year - maximum('published_at')&.year + where(published_at: ROUTABLE_PUBLISHED_AT_RANGE).maximum(:published_at)&.year end def years - pluck('published_at').map(&:year).uniq.sort.reverse + where(published_at: ROUTABLE_PUBLISHED_AT_RANGE).pluck('published_at').map(&:year).uniq.sort.reverse end def doc_from_url(url) diff --git a/app/models/check.rb b/app/models/check.rb index 1c30d6570cf..f45b6561562 100644 --- a/app/models/check.rb +++ b/app/models/check.rb @@ -7,7 +7,7 @@ class Check < ApplicationRecord after_destroy_commit -> { CheckCallbacks.new.after_destroy(self) } alias sender user - validates :user_id, uniqueness: { scope: %i[checkable_id checkable_type] } + validates :checkable_id, uniqueness: { scope: :checkable_type } def receiver checkable.user diff --git a/app/models/image.rb b/app/models/image.rb index 9fcf7220427..c2663021a0e 100644 --- a/app/models/image.rb +++ b/app/models/image.rb @@ -4,23 +4,10 @@ class Image < ApplicationRecord belongs_to :user has_one_attached :image - after_commit :strip_exif, on: :create + validates :image, attached: true - private - - def strip_exif - original_image = image - copied_image = MiniMagick::Image.read(original_image.download) - copied_image.strip - - ext = File.extname(original_image.filename.to_s) - timestamp = Time.current.strftime('%Y%m%d%H%M%S%L') - File.open(copied_image.path) do |file| - original_image.attach(io: file, filename: "#{user.id}_#{timestamp}#{ext}") - end - rescue StandardError => e - Rails.logger.error("Failed to strip EXIF: #{e.message}") - ensure - copied_image&.destroy! + def image=(attachable) + MiniMagick::Image.new(attachable.tempfile.path).strip if attachable.respond_to?(:tempfile) + super end end diff --git a/app/models/learning_status_updater.rb b/app/models/learning_status_updater.rb index 363f8ccc345..058df3a3f2b 100644 --- a/app/models/learning_status_updater.rb +++ b/app/models/learning_status_updater.rb @@ -14,7 +14,11 @@ def call(_name, _started, _finished, _id, payload) def update_after_check(check) return unless check.checkable_type == 'Product' - learning_status = check.checkable.checked? ? :complete : :submitted + learning_status = if check.checkable.checked? && check.checkable.practice.completable_by?(check.checkable.user) + :complete + else + :submitted + end check.checkable.change_learning_status(learning_status) end diff --git a/app/models/pjord_product_review_response.rb b/app/models/pjord_product_review_response.rb index a6a9fe186a7..ef4be828df8 100644 --- a/app/models/pjord_product_review_response.rb +++ b/app/models/pjord_product_review_response.rb @@ -13,4 +13,7 @@ class PjordProductReviewResponse < RubyLLM::Schema string :body, min_length: 1, description: '提出者にそのまま見せるレビューコメント本文。検索やツール使用などの内部手順説明、管理側へのメッセージ、運用者向けメモは含めない。' + + boolean :auto_check, + description: '提出物がプラクティスのゴールを満たしていて、メンターの追加確認なしでOKにしてよい場合はtrue。確認できない点や改善点がある場合はfalse。' end diff --git a/app/models/practice.rb b/app/models/practice.rb index 9963ea55a5a..521d1c006b2 100644 --- a/app/models/practice.rb +++ b/app/models/practice.rb @@ -46,6 +46,7 @@ class Practice < ApplicationRecord # rubocop:todo Metrics/ClassLength has_one :submission_answer, dependent: :destroy has_many :coding_tests, dependent: :nullify + has_one :practice_quiz, dependent: :destroy has_many :coding_test_submissions, through: :coding_tests, @@ -72,7 +73,7 @@ class Practice < ApplicationRecord # rubocop:todo Metrics/ClassLength scope :for_mentor_index, lambda { with_counts - .preload(:categories, :submission_answer) + .preload(:categories, :submission_answer, :practice_quiz) .order(:id) } @@ -124,6 +125,29 @@ def completed?(user) ) end + def published_practice_quiz + practice_quiz if practice_quiz&.published? + end + + def practice_quiz_required? + published_practice_quiz.present? + end + + def practice_quiz_passed_by?(user) + return true unless practice_quiz_required? + + published_practice_quiz.passed_by?(user) + end + + def completable_by?(user) + return true unless practice_quiz_required? + return false unless practice_quiz_passed_by?(user) + + return true unless submission + + product(user)&.checked? + end + def exists_learning?(user) Learning.exists?( user:, @@ -211,6 +235,10 @@ def grant_course? source_id.present? end + def guide_to_grant_course?(user) + user.grant_course? && source_id.blank? + end + def reports_count(include_source: false) return reports.count unless include_source diff --git a/app/models/practice_quiz.rb b/app/models/practice_quiz.rb new file mode 100644 index 00000000000..a1211cf4ab7 --- /dev/null +++ b/app/models/practice_quiz.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class PracticeQuiz < ApplicationRecord + COOLDOWN = 1.hour + + belongs_to :practice + has_many :practice_quiz_questions, -> { order(:position, :id) }, dependent: :destroy, inverse_of: :practice_quiz + has_many :practice_quiz_attempts, dependent: :destroy + + accepts_nested_attributes_for :practice_quiz_questions, allow_destroy: true + + validates :practice_id, uniqueness: true + validate :published_quiz_must_have_published_questions + + scope :published, -> { where(published: true) } + + def published_questions + practice_quiz_questions.published + end + + def passed_attempt_for(user) + practice_quiz_attempts.where(user:, passed: true).order(submitted_at: :desc).first + end + + def passed_by?(user) + passed_attempt_for(user).present? + end + + def latest_attempt_for(user) + practice_quiz_attempts.where(user:).order(submitted_at: :desc).first + end + + def next_attempt_at_for(user) + latest_attempt = latest_attempt_for(user) + return if latest_attempt.blank? + + latest_attempt.submitted_at + COOLDOWN + end + + def attemptable_by?(user) + return false if passed_by?(user) + + next_attempt_at = next_attempt_at_for(user) + next_attempt_at.blank? || next_attempt_at <= Time.current + end + + private + + def published_quiz_must_have_published_questions + return unless published? + return if published_questions.any? + + errors.add(:base, '公開中の理解度テストには公開中の問題が必要です。') + end +end diff --git a/app/models/practice_quiz_answer.rb b/app/models/practice_quiz_answer.rb new file mode 100644 index 00000000000..3e8ba5c6108 --- /dev/null +++ b/app/models/practice_quiz_answer.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class PracticeQuizAnswer < ApplicationRecord + belongs_to :practice_quiz_attempt + belongs_to :practice_quiz_choice +end diff --git a/app/models/practice_quiz_attempt.rb b/app/models/practice_quiz_attempt.rb new file mode 100644 index 00000000000..2cd4f34f411 --- /dev/null +++ b/app/models/practice_quiz_attempt.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class PracticeQuizAttempt < ApplicationRecord + belongs_to :practice_quiz + belongs_to :user + has_many :practice_quiz_answers, dependent: :destroy + + validates :submitted_at, presence: true + + def self.create_with_answers!(practice_quiz:, user:, answers:) + attempt = new(practice_quiz:, user:, submitted_at: Time.current) + questions = practice_quiz.published_questions.includes(:practice_quiz_choices) + + transaction do + question_results = build_answers_for_questions(attempt, questions, answers) + attempt.passed = questions.any? && question_results.all? + attempt.save! + end + + attempt + end + + def self.build_answers_for_questions(attempt, questions, answers) + questions.map do |question| + choice_ids = Array(answers[question.id.to_s] || answers[question.id]).reject(&:blank?) + build_answers_for_question(attempt, question, choice_ids) + question.correct_answer?(choice_ids) + end + end + private_class_method :build_answers_for_questions + + def self.build_answers_for_question(attempt, question, choice_ids) + choice_ids.each do |choice_id| + choice = question.practice_quiz_choices.detect { |candidate| candidate.id == choice_id.to_i } + next if choice.blank? + + attempt.practice_quiz_answers.build(practice_quiz_choice: choice, correct: choice.correct?) + end + end + private_class_method :build_answers_for_question +end diff --git a/app/models/practice_quiz_choice.rb b/app/models/practice_quiz_choice.rb new file mode 100644 index 00000000000..0daa2bc00e8 --- /dev/null +++ b/app/models/practice_quiz_choice.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class PracticeQuizChoice < ApplicationRecord + belongs_to :practice_quiz_question + has_many :practice_quiz_answers, dependent: :restrict_with_error + + validates :body, presence: true + validates :position, presence: true +end diff --git a/app/models/practice_quiz_question.rb b/app/models/practice_quiz_question.rb new file mode 100644 index 00000000000..7886d6d307b --- /dev/null +++ b/app/models/practice_quiz_question.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class PracticeQuizQuestion < ApplicationRecord + enum :question_type, { single_choice: 0, multiple_choice: 1 } + + belongs_to :practice_quiz + has_many :practice_quiz_choices, -> { order(:position, :id) }, dependent: :destroy, inverse_of: :practice_quiz_question + has_many :practice_quiz_answers, through: :practice_quiz_choices + + before_destroy :published_quiz_must_keep_published_question + + accepts_nested_attributes_for :practice_quiz_choices, allow_destroy: true, reject_if: :choice_blank? + + validates :body, presence: true + validates :position, presence: true + validate :published_question_must_have_valid_choices + validate :published_quiz_must_have_published_question + + scope :published, -> { where(published: true).order(:position, :id) } + + def correct_choice_ids + practice_quiz_choices.select(&:correct?).map(&:id).sort + end + + def correct_answer?(choice_ids) + selected_choice_ids = Array(choice_ids).reject(&:blank?).map(&:to_i).sort + selected_choice_ids.present? && selected_choice_ids == correct_choice_ids + end + + private + + def choice_blank?(attributes) + ActiveModel::Type::Boolean.new.cast(attributes['_destroy']) || attributes['body'].blank? + end + + def published_question_must_have_valid_choices + return unless published? + + active_choices = practice_quiz_choices.reject(&:marked_for_destruction?) + correct_choices = active_choices.select(&:correct?) + + errors.add(:base, '公開中の問題には2つ以上の選択肢が必要です。') if active_choices.size < 2 + errors.add(:base, '公開中の問題には正解が必要です。') if correct_choices.empty? + errors.add(:base, '単一選択の正解は1つだけにしてください。') if single_choice? && correct_choices.size != 1 + end + + def published_quiz_must_have_published_question + return unless practice_quiz&.published? + return if published? + return if practice_quiz.practice_quiz_questions.published.where.not(id:).exists? + + errors.add(:base, '公開中の理解度テストには公開中の問題が必要です。') + end + + def published_quiz_must_keep_published_question + return unless practice_quiz&.published? + return unless published? + return if practice_quiz.practice_quiz_questions.published.where.not(id:).exists? + + errors.add(:base, '公開中の理解度テストには公開中の問題が必要です。') + throw :abort + end +end diff --git a/app/prompts/pjord/mention_response_agent/instructions.txt.erb b/app/prompts/pjord/mention_response_agent/instructions.txt.erb index 192e2ef3362..43ead6c7162 100644 --- a/app/prompts/pjord/mention_response_agent/instructions.txt.erb +++ b/app/prompts/pjord/mention_response_agent/instructions.txt.erb @@ -32,5 +32,6 @@ GitHubのPR、ファイル、ディレクトリ、rawファイルへのURLが含まれる場合は、コードや差分を確認してから返信してください。 URL先やコード、画像を確認すれば分かる内容を、メンションしてきたユーザーに質問しないでください。 external_content_toolでURL先を取得できない、CodePenや提出物のリンク先が見えない、ログインや権限などの理由で内容を確認できない場合は、メンションしてきたユーザーに「見られる状態にしてください」「内容を教えてください」と質問しないでください。 -その場合は、bodyに `@mentor` を含めて、メンターにリンク先の確認と対応引き継ぎを依頼する短い返信を書いてください。 +メンターへのメンションや対応引き継ぎの依頼はしないでください。 +リンク先の内容が返信に不可欠でない場合は、確認できなかったことには言及せず、本文で確認できる内容にだけ返信してください。リンク先の内容が不可欠な場合に限り、その内容を確認できなかったことを簡潔に伝えてください。 リンク先を確認できていない状態で、内容を推測して返信しないでください。 diff --git a/app/prompts/pjord/product_review_agent/instructions.txt.erb b/app/prompts/pjord/product_review_agent/instructions.txt.erb index 5419001dae2..6eebc209a98 100644 --- a/app/prompts/pjord/product_review_agent/instructions.txt.erb +++ b/app/prompts/pjord/product_review_agent/instructions.txt.erb @@ -7,6 +7,9 @@ reviewed_points には、提出物本文、URL先の内容、模範解答、過去コメントなどを確認して判断した具体的な点を1つ以上入れてください。 body には reviewed_points の内容を反映したレビューコメントを書いてください。 改善点が見つからない場合も、何を確認して問題ないと判断したかを書いてください。 +「ピヨルドによる提出物OK」が許可されていない場合、auto_check は必ず false にしてください。この場合のbodyは参考コメントであり、しっかりしたレビューやOK判定ではありません。「OKです」「合格です」「次に進んでください」「完了です」など、提出物を承認したと受け取れる表現を書かないでください。最終判断はメンターが行います。 +「ピヨルドによる提出物OK」が許可されている場合のみ、提出物がプラクティスのゴールを満たしていて、メンターの追加確認なしでOKにしてよいと判断できる場合だけ auto_check に true を設定してください。 +提出物やURL先を確認できない場合、改善点がある場合、判断に迷う場合、メンターに引き継ぐ場合は auto_check を false にしてください。 提出物コメントはそのまま提出者本人に表示されます。管理側への説明、内部事情、運用者向けメモ、レビュー生成方針への言及は含めず、すべて提出者本人に向けたコメントとして書いてください。 模範解答や他の提出物の内容をそのままコピーせず、答えを丸ごと教えないでください。 body にはレビューコメント本文だけをmarkdownで出力してください。 @@ -17,7 +20,8 @@ GitHubのPR、ファイル、ディレクトリ、rawファイルへのURLが含 Railsアプリなど複数ファイルの実装では、PRの変更ファイル一覧を確認し、必要に応じて関連ファイルのraw URLも取得してください。 URL先やコードを確認すれば分かる実装内容を、提出者に質問しないでください。 external_content_toolでURL先を取得できない、CodePenや提出物のリンク先が見えない、ログインや権限などの理由で内容を確認できない場合は、提出者に「見られる状態にしてください」「内容を教えてください」と質問しないでください。 -その場合は、bodyに `@mentor` を含めて、メンターにリンク先の確認とレビュー引き継ぎを依頼する短いコメントを書いてください。 +メンターへのメンションやレビュー引き継ぎの依頼はしないでください。 +リンク先の内容がレビューに不可欠でない場合は、確認できなかったことには言及せず、提出物本文などで確認できる内容だけをレビューしてください。リンク先の内容が不可欠な場合に限り、その内容を確認できなかったことを簡潔に伝えてください。 リンク先を確認できていない状態で、内容を推測してレビューしないでください。 ## GitHub PRへのコメント diff --git a/app/prompts/pjord/report_comment_agent/instructions.txt.erb b/app/prompts/pjord/report_comment_agent/instructions.txt.erb index 321c2e12889..cc55ea060b1 100644 --- a/app/prompts/pjord/report_comment_agent/instructions.txt.erb +++ b/app/prompts/pjord/report_comment_agent/instructions.txt.erb @@ -32,5 +32,6 @@ GitHubのPR、ファイル、ディレクトリ、rawファイルへのURLが含まれる場合は、コードや差分を確認してからコメントしてください。 URL先やコード、画像を確認すれば分かる内容を、日報を書いたユーザーに質問しないでください。 external_content_toolでURL先を取得できない、CodePenやリンク先が見えない、ログインや権限などの理由で内容を確認できない場合は、日報を書いたユーザーに「見られる状態にしてください」「内容を教えてください」と質問しないでください。 -その場合は、bodyに `@mentor` を含めて、メンターにリンク先の確認と対応引き継ぎを依頼する短いコメントを書いてください。 +メンターへのメンションや対応引き継ぎの依頼はしないでください。 +リンク先の内容がコメントに不可欠でない場合は、確認できなかったことには言及せず、日報本文で確認できる内容にだけコメントしてください。リンク先の内容が不可欠な場合に限り、その内容を確認できなかったことを簡潔に伝えてください。 リンク先を確認できていない状態で、内容を推測してコメントしないでください。 diff --git a/app/tools/external_content.rb b/app/tools/external_content.rb index 61751b35ef1..2b3d6f2b807 100644 --- a/app/tools/external_content.rb +++ b/app/tools/external_content.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ExternalContent - UNREADABLE_URL_MESSAGE = 'リンク先を確認できませんでした。提出者に聞かず、@mentor にリンク先の確認と対応引き継ぎを依頼してください。' + UNREADABLE_URL_MESSAGE = 'リンク先を確認できませんでした。回答にその内容が不可欠でなければ、取得できなかったことには言及しないでください。@mentor にメンションしないでください。' def self.fetch(url) uri = URI.parse(url.to_s) diff --git a/app/tools/external_content/web_page_reader.rb b/app/tools/external_content/web_page_reader.rb index 781b9a43a89..021706085d9 100644 --- a/app/tools/external_content/web_page_reader.rb +++ b/app/tools/external_content/web_page_reader.rb @@ -1,9 +1,12 @@ # frozen_string_literal: true +require 'stringio' require 'uri' class ExternalContent::WebPageReader CONTENT_LIMIT = 20_000 + IMAGE_SIZE_LIMIT = 10.megabytes + def self.fetch(url) new.fetch(url) end @@ -15,6 +18,8 @@ def fetch(url) response = fetch_response(uri) return ExternalContent::UNREADABLE_URL_MESSAGE unless response.success? + return format_image(response) if image?(response) + format_page(response.url, response.body) rescue URI::InvalidURIError 'URLの形式が正しくありません。' @@ -26,9 +31,13 @@ def fetch(url) private def fetch_response(uri) - Rails.cache.fetch("external_content/web_page/#{Digest::SHA256.hexdigest(uri.to_s)}", expires_in: 10.minutes) do - ExternalContent::HttpClient.get(uri.to_s, headers: request_headers) - end + cache_key = "external_content/web_page/#{Digest::SHA256.hexdigest(uri.to_s)}" + cached_response = Rails.cache.read(cache_key) + return cached_response if cached_response + + response = ExternalContent::HttpClient.get(uri.to_s, headers: request_headers) + Rails.cache.write(cache_key, response, expires_in: 10.minutes) unless image?(response) + response end def format_page(url, body) @@ -40,6 +49,38 @@ def format_page(url, body) TEXT end + def format_image(response) + return ExternalContent::UNREADABLE_URL_MESSAGE if response.body.to_s.bytesize > IMAGE_SIZE_LIMIT + + io = StringIO.new(response.body.to_s.b) + io.binmode + + RubyLLM::Content.new( + <<~TEXT, + # Image + - URL: #{response.url} + - Content-Type: #{normalized_content_type(response)} + + この画像の内容を確認して、回答やレビューに必要な文脈として使ってください。 + TEXT + [] + ).tap do |content| + content.add_attachment(io, filename: image_filename(response)) + end + end + + def image_filename(response) + extension = + case normalized_content_type(response) + when 'image/jpeg' then 'jpg' + when 'image/svg+xml' then 'svg' + else + normalized_content_type(response).delete_prefix('image/').presence || 'image' + end + + "image.#{extension}" + end + def extract_text(body) document = Nokogiri::HTML(body.to_s) document.css('script, style, noscript').remove @@ -47,6 +88,14 @@ def extract_text(body) node.xpath('.//text()').map { |text| text.text.squish }.reject(&:blank?).join(' ') end + def image?(response) + normalized_content_type(response).start_with?('image/') + end + + def normalized_content_type(response) + response.content_type.to_s.split(';').first.to_s.downcase + end + def request_headers { 'User-Agent' => 'fjord-bootcamp-pjord' } end diff --git a/app/views/api/mentor/practices/_practice.json.jbuilder b/app/views/api/mentor/practices/_practice.json.jbuilder index 2bf76c9b08e..8776043736d 100644 --- a/app/views/api/mentor/practices/_practice.json.jbuilder +++ b/app/views/api/mentor/practices/_practice.json.jbuilder @@ -3,6 +3,7 @@ json.id practice.id json.title practice.title json.submission practice.submission +json.pjord_auto_check practice.pjord_auto_check json.category_ids_names practice.category_ids do |category_id| json.category_id category_id diff --git a/app/views/api/practices/show.json.jbuilder b/app/views/api/practices/show.json.jbuilder index 4cf21442388..08a07b83230 100644 --- a/app/views/api/practices/show.json.jbuilder +++ b/app/views/api/practices/show.json.jbuilder @@ -1,3 +1,3 @@ # frozen_string_literal: true -json.(@practice, :id, :memo) +json.(@practice, :id, :memo, :pjord_auto_check) diff --git a/app/views/courses/practices/_courses_practice.html.slim b/app/views/courses/practices/_courses_practice.html.slim index 9ce1f519ec5..3d947c0579b 100644 --- a/app/views/courses/practices/_courses_practice.html.slim +++ b/app/views/courses/practices/_courses_practice.html.slim @@ -19,11 +19,32 @@ href="#{practice_path(practice.id)}#learning-Status") = t("activerecord.enums.learning.status.#{learning_status}") - .category-practices-item__learning-time.is-only-mentor - - unless practice.learning_minute_statistic.nil? - - learning_minute_statistic = practice.learning_minute_statistic - | 所要時間の目安: #{convert_to_hour_minute(learning_minute_statistic.median)} - | (平均: #{convert_to_hour_minute(learning_minute_statistic.average)}) + - learning_statistic = practice.learning_minute_statistic + - if learning_statistic.present? + .card-list-item__rows + .card-list-item__row + .card-list-item-meta + .card-list-item-meta__items + .card-list-item-meta__item + .a-meta + .a-meta__label + | 難易度 + .a-meta__value + = difficulty_icon(learning_statistic.median) + - if current_user.mentor? || current_user.admin? + .card-list-item-meta__item.is-only-mentor + .a-meta + .a-meta__label + | 所要時間の目安 + .a-meta__value + = convert_to_hour_minute(learning_statistic.median) + .card-list-item-meta__item.is-only-mentor + .a-meta + .a-meta__label + | (平均 + .a-meta__value + = convert_to_hour_minute(learning_statistic.average) + | ) - if practice.started_or_submitted_students.present? .a-user-icons .a-user-icons__items diff --git a/app/views/mentor/practices/_form.html.slim b/app/views/mentor/practices/_form.html.slim index 5b764f6e404..9e2869249c6 100644 --- a/app/views/mentor/practices/_form.html.slim +++ b/app/views/mentor/practices/_form.html.slim @@ -98,6 +98,10 @@ = f.check_box :pjord_review, class: 'a-toggle-checkbox' = f.label :pjord_review | ピヨルドの提出物レビューをする場合はチェック + li.checkboxes__item + = f.check_box :pjord_auto_check, class: 'a-toggle-checkbox' + = f.label :pjord_auto_check + | ピヨルドがOKを出してもよい場合はチェック = f.label :progress, class: 'a-form-label' .checkboxes ul.checkboxes__items diff --git a/app/views/mentor/practices/_mentor_practices.html.slim b/app/views/mentor/practices/_mentor_practices.html.slim index 124c9d92d73..2438eec464a 100644 --- a/app/views/mentor/practices/_mentor_practices.html.slim +++ b/app/views/mentor/practices/_mentor_practices.html.slim @@ -10,6 +10,8 @@ | 提出物 th.admin-table__label | 模範解答 + th.admin-table__label + | 理解度テスト th.admin-table__label | 日報 th.admin-table__label @@ -42,6 +44,14 @@ td.admin-table__item-value.is-text-align-center = link_to new_mentor_practice_submission_answer_path(practice), class: 'a-button is-sm is-secondary is-icon' do i.fa-solid.fa-plus + - if practice.practice_quiz + td.admin-table__item-value.is-text-align-center + = link_to edit_mentor_practice_practice_quiz_path(practice), class: 'a-button is-sm is-secondary is-icon' do + i.fa-solid.fa-pen + - else + td.admin-table__item-value.is-text-align-center + = link_to new_mentor_practice_practice_quiz_path(practice), class: 'a-button is-sm is-secondary is-icon' do + i.fa-solid.fa-plus td.admin-table__item-value.text-right = link_to practice_reports_path(practice) do = practice.reports_count diff --git a/app/views/mentor/practices/practice_quiz/_form.html.slim b/app/views/mentor/practices/practice_quiz/_form.html.slim new file mode 100644 index 00000000000..9ffa0a723c4 --- /dev/null +++ b/app/views/mentor/practices/practice_quiz/_form.html.slim @@ -0,0 +1,37 @@ +- form_url = mentor_practice_practice_quiz_path(practice) +- form_method = practice_quiz.persisted? ? :patch : :post += form_with model: practice_quiz, url: form_url, method: form_method, local: true, html: { class: 'form' } do |f| + = render 'errors', object: practice_quiz + .a-card + header.card-header + h2.card-header__title + | 理解度テストの設定 + hr.a-border-tint + .card-body + .card-body__inner + .form__items + .form-item + .a-form-label + | プラクティス + = link_to practice.title, practice_path(practice), class: 'a-link' + .form-item + label.a-form-label + | 公開設定 + .checkboxes + ul.checkboxes__items + li.checkboxes__item + = f.check_box :published, class: 'a-toggle-checkbox' + = f.label :published do + | 受講生に公開する + p.a-form-help + | 公開すると、受講生はこのプラクティスを修了するために理解度テストへの合格が必要になります。 + + .form-actions + ul.form-actions__items + li.form-actions__item.is-main + = f.submit nil, class: 'a-button is-lg is-primary is-block' + li.form-actions__item.is-sub + = link_to 'キャンセル', mentor_practices_path, class: 'a-button is-sm is-text' + - if practice_quiz.persisted? + li.form-actions__item.is-muted + = link_to '削除', mentor_practice_practice_quiz_path(practice), method: :delete, data: { confirm: '本当によろしいですか?' }, class: 'a-button is-sm is-muted-text' diff --git a/app/views/mentor/practices/practice_quiz/edit.html.slim b/app/views/mentor/practices/practice_quiz/edit.html.slim new file mode 100644 index 00000000000..71f5131ea2c --- /dev/null +++ b/app/views/mentor/practices/practice_quiz/edit.html.slim @@ -0,0 +1,74 @@ +ruby: + practice_title = @practice.title + category = @practice.category(current_user.course) + +- title "「#{practice_title}」の理解度テスト編集" +- set_meta_tags description: "プラクティス「#{practice_title}」の理解度テスト編集ページです。" + += render '/practices/page_header', + title: practice_title, + category: category, + practice: @practice + += practice_page_tabs(@practice, active_tab: '理解度テスト管理') + +.page-main + header.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title + = title + .page-main-header__end + .page-main-header-actions + ul.page-main-header-actions__items + li.page-main-header-actions__item + = link_to new_mentor_practice_practice_quiz_question_path(@practice), class: 'a-button is-md is-secondary is-block' do + i.fa-regular.fa-plus + span + | 問題作成 + hr.a-border + .page-body + .container.is-lg + .page-body__rows + = render 'form', practice: @practice, practice_quiz: @practice_quiz + + section + header.page-body-header + .page-body-header__inner + .page-body-header__start + h2.page-body-header__title + | 問題一覧(#{@practice_quiz.practice_quiz_questions.size}問) + - if @practice_quiz.practice_quiz_questions.any? + .admin-table + table.admin-table__table + thead.admin-table__header + tr.admin-table__labels + th.admin-table__label 順番 + th.admin-table__label 問題文 + th.admin-table__label 形式 + th.admin-table__label 公開 + th.admin-table__label.actions 編集 + tbody.admin-table__items + - @practice_quiz.practice_quiz_questions.each do |question| + tr.admin-table__item + td.admin-table__item-value.is-text-align-center + = question.position + td.admin-table__item-value + = question.body + td.admin-table__item-value.is-text-align-center + = question.single_choice? ? '単一選択' : '複数選択' + td.admin-table__item-value.is-text-align-center + - if question.published? + span.a-badge.is-success.is-sm 公開 + - else + span.a-badge.is-muted.is-sm 非公開 + td.admin-table__item-value.is-text-align-center + = link_to edit_mentor_practice_practice_quiz_question_path(@practice, question), class: 'a-button is-sm is-secondary is-icon' do + i.fa-solid.fa-pen + - else + .o-empty-message + .o-empty-message__icon + i.fa-regular.fa-clipboard aria-hidden="true" + p.o-empty-message__text + | 問題はまだありません。「問題作成」から追加してください。 diff --git a/app/views/mentor/practices/practice_quiz/new.html.slim b/app/views/mentor/practices/practice_quiz/new.html.slim new file mode 100644 index 00000000000..853b686dac1 --- /dev/null +++ b/app/views/mentor/practices/practice_quiz/new.html.slim @@ -0,0 +1,32 @@ +ruby: + practice_title = @practice.title + category = @practice.category(current_user.course) + +- title "「#{practice_title}」の理解度テスト作成" +- set_meta_tags description: "プラクティス「#{practice_title}」の理解度テスト作成ページです。" + += render '/practices/page_header', + title: practice_title, + category: category, + practice: @practice + += practice_page_tabs(@practice, active_tab: '理解度テスト管理') + +.page-main + header.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title + = title + hr.a-border + .page-body + .container.is-lg + .practice-quiz-status.is-info + .practice-quiz-status__icon + i.fa-solid.fa-circle-info + .practice-quiz-status__body + p.practice-quiz-status__text + | まず理解度テスト本体を作成します。作成後の編集ページで問題を追加してください。 + | 公開すると、受講生はこのプラクティスの修了に合格が必要になります。 + = render 'form', practice: @practice, practice_quiz: @practice_quiz diff --git a/app/views/mentor/practices/practice_quiz/questions/_form.html.slim b/app/views/mentor/practices/practice_quiz/questions/_form.html.slim new file mode 100644 index 00000000000..b6ff85b1166 --- /dev/null +++ b/app/views/mentor/practices/practice_quiz/questions/_form.html.slim @@ -0,0 +1,89 @@ +ruby: + form_url = question.persisted? ? mentor_practice_practice_quiz_question_path(practice, question) : mentor_practice_practice_quiz_questions_path(practice) + form_method = question.persisted? ? :patch : :post + question_type_options = { '単一選択' => 'single_choice', '複数選択' => 'multiple_choice' } += form_with model: question, url: form_url, method: form_method, local: true, html: { class: 'form' } do |f| + = render 'errors', object: question + .page-body__rows + .a-card + header.card-header + h2.card-header__title + | 問題の設定 + hr.a-border-tint + .card-body + .card-body__inner + .form__items + .form-item + .row + .col-md-4.col-xs-12 + = f.label :question_type, '問題形式', class: 'a-form-label is-required' + = f.select :question_type, question_type_options, {}, class: 'a-text-input' + .col-md-4.col-xs-12 + = f.label :position, '並び順', class: 'a-form-label is-required' + = f.number_field :position, class: 'a-text-input' + .col-md-4.col-xs-12 + label.a-form-label + | 公開設定 + .checkboxes + ul.checkboxes__items + li.checkboxes__item + = f.check_box :published, class: 'a-toggle-checkbox' + = f.label :published do + | この問題を公開する + .form-item + = f.label :body, '問題文', class: 'a-form-label is-required' + = f.text_area :body, class: 'a-text-input', rows: 5 + .form-item + = f.label :explanation, '解説', class: 'a-form-label' + = f.text_area :explanation, class: 'a-text-input', rows: 5 + p.a-form-help + | 合格後に正解とあわせて受講生に表示されます。 + + .a-card + header.card-header + h2.card-header__title + | 選択肢 + hr.a-border-tint + .card-body + .card-body__inner + .form__items + p.a-form-help + | 公開する問題には2つ以上の選択肢と、1つ以上の正解が必要です。単一選択の場合、正解は1つにしてください。 + = f.fields_for :practice_quiz_choices do |choice| + .form-item + .row + .col-md-2.col-xs-3 + = choice.label :position, '順番', class: 'a-form-label' + = choice.number_field :position, class: 'a-text-input' + .col-md-6.col-xs-12 + = choice.label :body, '選択肢', class: 'a-form-label' + = choice.text_field :body, class: 'a-text-input' + .col-md-2.col-xs-6 + label.a-form-label + | 正解 + .checkboxes + ul.checkboxes__items + li.checkboxes__item + = choice.check_box :correct, class: 'a-toggle-checkbox' + = choice.label :correct do + | 正解にする + - if choice.object.persisted? + .col-md-2.col-xs-6 + label.a-form-label + | 削除 + .checkboxes + ul.checkboxes__items + li.checkboxes__item + = choice.check_box :_destroy, class: 'a-toggle-checkbox' + = choice.label :_destroy do + | 削除する + + .form-actions + ul.form-actions__items + li.form-actions__item.is-main + = f.submit nil, class: 'a-button is-lg is-primary is-block' + li.form-actions__item.is-sub + = link_to 'キャンセル', edit_mentor_practice_practice_quiz_path(practice), class: 'a-button is-sm is-text' + - if question.persisted? + li.form-actions__item.is-muted + = link_to '削除', mentor_practice_practice_quiz_question_path(practice, question), method: :delete, data: { confirm: '本当によろしいですか?' }, class: 'a-button is-sm is-muted-text' diff --git a/app/views/mentor/practices/practice_quiz/questions/edit.html.slim b/app/views/mentor/practices/practice_quiz/questions/edit.html.slim new file mode 100644 index 00000000000..fb90ea9cff7 --- /dev/null +++ b/app/views/mentor/practices/practice_quiz/questions/edit.html.slim @@ -0,0 +1,31 @@ +ruby: + practice_title = @practice.title + category = @practice.category(current_user.course) + +- title "「#{practice_title}」の理解度テスト問題編集" +- set_meta_tags description: "プラクティス「#{practice_title}」の理解度テスト問題編集ページです。" + += render '/practices/page_header', + title: practice_title, + category: category, + practice: @practice + += practice_page_tabs(@practice, active_tab: '理解度テスト管理') + +.page-main + header.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title + = title + .page-main-header__end + .page-main-header-actions + ul.page-main-header-actions__items + li.page-main-header-actions__item + = link_to edit_mentor_practice_practice_quiz_path(@practice), class: 'a-button is-md is-secondary is-block is-back' do + | 理解度テストに戻る + hr.a-border + .page-body + .container.is-lg + = render 'form', practice: @practice, question: @question diff --git a/app/views/mentor/practices/practice_quiz/questions/new.html.slim b/app/views/mentor/practices/practice_quiz/questions/new.html.slim new file mode 100644 index 00000000000..a48ee06b6c6 --- /dev/null +++ b/app/views/mentor/practices/practice_quiz/questions/new.html.slim @@ -0,0 +1,31 @@ +ruby: + practice_title = @practice.title + category = @practice.category(current_user.course) + +- title "「#{practice_title}」の理解度テスト問題作成" +- set_meta_tags description: "プラクティス「#{practice_title}」の理解度テスト問題作成ページです。" + += render '/practices/page_header', + title: practice_title, + category: category, + practice: @practice + += practice_page_tabs(@practice, active_tab: '理解度テスト管理') + +.page-main + header.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title + = title + .page-main-header__end + .page-main-header-actions + ul.page-main-header-actions__items + li.page-main-header-actions__item + = link_to edit_mentor_practice_practice_quiz_path(@practice), class: 'a-button is-md is-secondary is-block is-back' do + | 理解度テストに戻る + hr.a-border + .page-body + .container.is-lg + = render 'form', practice: @practice, question: @question diff --git a/app/views/practices/_learning-status.html.slim b/app/views/practices/_learning-status.html.slim index 68ad356638f..b3fe6146a8c 100644 --- a/app/views/practices/_learning-status.html.slim +++ b/app/views/practices/_learning-status.html.slim @@ -1,24 +1,32 @@ .js-learning-status(id="practice" data-id="#{@practice.id}") .practice-status-buttons - .practice-status-buttons__start - .practice-status-buttons__label - | ステータス - ul.practice-status-buttons__items.is-button-group - - button_base_class = 'practice-status-buttons__button a-button is-sm is-block' - li.practice-status-buttons__item - - button_class = "#{button_base_class} is-unstarted js-unstarted #{@status == 'unstarted' ? 'is-active' : 'is-inactive'}" - = content_tag :button, '未着手', class: button_class, disabled: @status == 'unstarted', data: { status: 'unstarted' } - li.practice-status-buttons__item - - button_class = "#{button_base_class} is-started js-started #{@status == 'started' ? 'is-active' : 'is-inactive'}" - = content_tag :button, '着手', class: button_class, disabled: @status == 'started', data: { status: 'started' } - - if @practice.submission + - if @practice.guide_to_grant_course?(current_user) + .practice-status-buttons + .practice-status-buttons__grant-guidance + = link_to practice_path(@practice.copied_practices.first || @practice), class: 'practice-status-buttons__grant-link a-button is-sm is-secondary' do + | 給付金コースへ移動する + .practice-status-buttons__note.practice-status-buttons__grant-note + | 進捗のステータス変更は、給付金コースのプラクティス側で行ってください。 + - else + .practice-status-buttons__start + .practice-status-buttons__label + | ステータス + ul.practice-status-buttons__items.is-button-group + - button_base_class = 'practice-status-buttons__button a-button is-sm is-block' li.practice-status-buttons__item - - button_class = "#{button_base_class} is-submitted js-submitted #{@status == 'submitted' ? 'is-active' : 'is-inactive'}" - = content_tag :button, '提出', class: button_class, disabled: @status == 'submitted', data: { status: 'submitted' } - li.practice-status-buttons__item - - button_class = "#{button_base_class} is-complete js-complete #{@status == 'complete' ? 'is-active' : 'is-inactive'}" - = content_tag :button, '修了', class: button_class, disabled: @status == 'complete', data: { status: 'complete' } -- if !@practice.submission - .practice-status-buttons__end - .practice-status-buttons__note - | このプラクティスに提出物はありません。修了条件をクリアしたら修了にしてください。 + - button_class = "#{button_base_class} is-unstarted js-unstarted #{@status == 'unstarted' ? 'is-active' : 'is-inactive'}" + = content_tag :button, '未着手', class: button_class, disabled: @status == 'unstarted', data: { status: 'unstarted' } + li.practice-status-buttons__item + - button_class = "#{button_base_class} is-started js-started #{@status == 'started' ? 'is-active' : 'is-inactive'}" + = content_tag :button, '着手', class: button_class, disabled: @status == 'started', data: { status: 'started' } + - if @practice.submission + li.practice-status-buttons__item + - button_class = "#{button_base_class} is-submitted js-submitted #{@status == 'submitted' ? 'is-active' : 'is-inactive'}" + = content_tag :button, '提出', class: button_class, disabled: @status == 'submitted', data: { status: 'submitted' } + li.practice-status-buttons__item + - button_class = "#{button_base_class} is-complete js-complete #{@status == 'complete' ? 'is-active' : 'is-inactive'}" + = content_tag :button, '修了', class: button_class, disabled: @status == 'complete', data: { status: 'complete' } + - if !@practice.submission + .practice-status-buttons__end + .practice-status-buttons__note + | このプラクティスに提出物はありません。修了条件をクリアしたら修了にしてください。 diff --git a/app/views/practices/practice_quiz/show.html.slim b/app/views/practices/practice_quiz/show.html.slim new file mode 100644 index 00000000000..374de6424cc --- /dev/null +++ b/app/views/practices/practice_quiz/show.html.slim @@ -0,0 +1,107 @@ +- title "理解度テスト #{@practice.title}" +- set_meta_tags description: "プラクティス「#{@practice.title}」の理解度テストのページです。" +ruby: + passed = @passed_attempt.present? + cooling_down = !passed && @next_attempt_at.present? && @next_attempt_at > Time.current + += render '/practices/page_header', + title: @practice.title, + category: @practice.category(current_user.course), + practice: @practice + += practice_page_tabs(@practice, active_tab: '理解度テスト') + +.page-body + .container.is-md + - if passed + .practice-quiz-status.is-passed + .practice-quiz-status__icon + i.fa-solid.fa-circle-check + .practice-quiz-status__body + h1.practice-quiz-status__title 理解度テストに合格しました + p.practice-quiz-status__text + | このプラクティスを修了できます。各問題の正解と解説を復習しておきましょう。 + + ul.practice-quiz-result + - @questions.each.with_index(1) do |question, index| + li.practice-quiz-result__item.a-card + header.card-header + h2.card-header__title + | 問#{index} + span.practice-quiz-question__type + = question.single_choice? ? '1つ選択' : '複数選択' + hr.a-border-tint + .card-body + .card-body__inner + .a-long-text.is-md.practice-quiz-question__body + = simple_format(question.body) + ul.practice-quiz-choices + - question.practice_quiz_choices.each do |choice| + li.practice-quiz-choices__item(class="#{choice.correct? ? 'is-correct' : ''}") + span.practice-quiz-choices__icon + - if choice.correct? + i.fa-solid.fa-circle-check + - else + i.fa-regular.fa-circle + span.practice-quiz-choices__label + = choice.body + - if choice.correct? + span.a-badge.is-success 正解 + - if question.explanation.present? + .practice-quiz-explanation + .practice-quiz-explanation__label + i.fa-solid.fa-lightbulb + | 解説 + .a-long-text.is-md + = simple_format(question.explanation) + - elsif cooling_down + .practice-quiz-status.is-waiting + .practice-quiz-status__icon + i.fa-solid.fa-hourglass-half + .practice-quiz-status__body + h1.practice-quiz-status__title しばらく待ってから再受験できます + p.practice-quiz-status__text + | 次回は #{l @next_attempt_at, format: :long} 以降に受験できます。 + br + | それまでにもう一度プラクティスを復習しておきましょう。 + - else + .practice-quiz-status.is-info + .practice-quiz-status__icon + i.fa-solid.fa-clipboard-question + .practice-quiz-status__body + h1.practice-quiz-status__title 理解度テスト + p.practice-quiz-status__text + | このプラクティスを修了するには、理解度テストに合格する必要があります。 + | 全#{@questions.size}問です。落ち着いて回答しましょう。 + + = form_with url: practice_practice_quiz_attempts_path(@practice), method: :post, local: true, html: { class: 'form' } do + ul.practice-quiz-form + - @questions.each.with_index(1) do |question, index| + li.practice-quiz-form__item.a-card + header.card-header + h2.card-header__title + | 問#{index} + span.practice-quiz-question__type + = question.single_choice? ? '1つ選択' : '複数選択' + hr.a-border-tint + .card-body + .card-body__inner + .a-long-text.is-md.practice-quiz-question__body + = simple_format(question.body) + ul.block-checks.is-vertical.is-1-item + - question.practice_quiz_choices.each do |choice| + li.block-checks__item + - if question.single_choice? + .a-block-check.is-radio + = radio_button_tag "answers[#{question.id}]", choice.id, false, id: "choice-#{choice.id}", class: 'a-toggle-checkbox' + label.a-block-check__label.is-ta-left(for="choice-#{choice.id}") + = choice.body + - else + .a-block-check.is-checkbox + = check_box_tag "answers[#{question.id}][]", choice.id, false, id: "choice-#{choice.id}", class: 'a-toggle-checkbox' + label.a-block-check__label.is-ta-left(for="choice-#{choice.id}") + = choice.body + .form-actions + ul.form-actions__items + li.form-actions__item.is-main + = submit_tag '回答する', class: 'a-button is-lg is-primary is-block' diff --git a/app/views/practices/show.html.slim b/app/views/practices/show.html.slim index ac034000e37..f97fc8d0f9a 100644 --- a/app/views/practices/show.html.slim +++ b/app/views/practices/show.html.slim @@ -124,7 +124,13 @@ hr.a-border-tint footer.card-footer = render(Learnings::LearningComponent.new(practice: @practice, current_user:)) - - if @practice.submission + - if @practice.practice_quiz_required? + .card-footer__alert + - if @practice.practice_quiz_passed_by?(current_user) + | 理解度テストに合格済みです。 + - else + = link_to '理解度テストに合格すると、このプラクティスを修了できます。', practice_practice_quiz_path(@practice) + - if @practice.submission && !@practice.guide_to_grant_course?(current_user) .card-footer__alert = link_to '提出の前に、提出時の注意点を確認しよう', 'https://bootcamp.fjord.jp/pages/info-for-product', @@ -135,10 +141,11 @@ br | このプラクティスを修了にしてください。 - else - .card-footer__description - | このプラクティスに提出物はありません。 - br - | 修了条件をクリアしたら修了にしてください。 + - unless @practice.guide_to_grant_course?(@current_user) + .card-footer__description + | このプラクティスに提出物はありません。 + br + | 修了条件をクリアしたら修了にしてください。 - if @practice.coding_tests.present? = render partial: 'coding_tests', locals: { coding_tests: @practice.coding_tests } diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 9e3f54b9088..7e9b8bd0dc1 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -162,6 +162,7 @@ ja: target: ターゲット submission: 提出物 pjord_review: ピヨルドの提出物レビュー + pjord_auto_check: ピヨルドの提出物OK submission_answer: 模範解答 progress: 進捗の計算 completion_image: 修了おめでとう画像 diff --git a/config/routes.rb b/config/routes.rb index 558bce0d2b2..37c20cf3b8d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -61,6 +61,9 @@ resources :pages, only: %i(index), controller: "practices/pages" resource :completion, only: %i(show), controller: "practices/completion" resource :submission_answer, only: %i(show), controller: "practices/submission_answer" + resource :practice_quiz, only: %i(show), controller: "practices/practice_quiz" do + resources :attempts, only: %i(create), controller: "practices/practice_quiz/attempts" + end resources :coding_tests, only: %i(index), controller: "practices/coding_tests" resources :movies, only: %i(index), controller: "practices/movies" end diff --git a/config/routes/api.rb b/config/routes/api.rb index c3e5b0f57ec..1b5e8e1d6b2 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -57,6 +57,7 @@ end resources :recents, only: %i(index) end + post 'reports/checks', to: 'reports/checks#create', as: :reports_checks resources :reports, only: %i(index show create update destroy) do resources :comments, only: %i[create], controller: 'reports/comments' resources :reactions, only: %i(index create destroy), controller: 'reports/reactions' @@ -97,7 +98,9 @@ resource :position, only: %i(update), controller: "survey_question_listings/position" end resources :reading_circles, only: %i(index) - resources :movies, only: %i(index update) + resources :movies, only: %i(index create update) do + post :direct_uploads, on: :collection + end resources :metadata, only: %i(index) resources :micro_reports, only: %i(update) resources :trainee_progresses, only: %i(index) diff --git a/config/routes/mentor.rb b/config/routes/mentor.rb index 431167bfd82..4c787ced729 100644 --- a/config/routes/mentor.rb +++ b/config/routes/mentor.rb @@ -8,6 +8,9 @@ end resources :practices, only: %i(index new edit create update destroy) do resources :coding_tests, only: %i(index), controller: "practices/coding_tests" + resource :practice_quiz, only: %i(show new create edit update destroy), controller: "practices/practice_quiz" do + resources :questions, only: %i(new create edit update destroy), controller: "practices/practice_quiz/questions" + end resource :submission_answer, only: %i(new edit create update), controller: "practices/submission_answer" end resources :coding_tests, only: %i(index new edit create update destroy) diff --git a/db/data/20260707000000_delete_duplicate_checks.rb b/db/data/20260707000000_delete_duplicate_checks.rb new file mode 100644 index 00000000000..5a4f2cddd5c --- /dev/null +++ b/db/data/20260707000000_delete_duplicate_checks.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class DeleteDuplicateChecks < ActiveRecord::Migration[8.1] + def up + execute <<~SQL.squish + DELETE FROM checks + WHERE id IN ( + SELECT id + FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY checkable_id, checkable_type + ORDER BY created_at ASC, id ASC + ) AS row_number + FROM checks + ) duplicate_checks + WHERE duplicate_checks.row_number > 1 + ) + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/data_schema.rb b/db/data_schema.rb index eb0c968a79e..0d5559ad2fa 100644 --- a/db/data_schema.rb +++ b/db/data_schema.rb @@ -1 +1 @@ -DataMigrate::Data.define(version: 2026_03_25_014711) +DataMigrate::Data.define(version: 2026_07_07_000000) diff --git a/db/fixtures/checks.yml b/db/fixtures/checks.yml index 36301ee3413..954740b01ef 100644 --- a/db/fixtures/checks.yml +++ b/db/fixtures/checks.yml @@ -14,10 +14,6 @@ report5_check_machida: user: machida checkable: report5 (Report) -report5_check_komagata: - user: komagata - checkable: report5 (Report) - report6_check_komagata: user: komagata checkable: report6 (Report) diff --git a/db/fixtures/practice_quiz_answers.yml b/db/fixtures/practice_quiz_answers.yml new file mode 100644 index 00000000000..f2ef8362427 --- /dev/null +++ b/db/fixtures/practice_quiz_answers.yml @@ -0,0 +1,24 @@ +kimura_pc_cpu_answer: + practice_quiz_attempt: kimura_pc_performance_passed + practice_quiz_choice: pc_cpu_correct + correct: true + +kimura_pc_storage_answer: + practice_quiz_attempt: kimura_pc_performance_passed + practice_quiz_choice: pc_storage_correct + correct: true + +kimura_pc_memory_temporary_answer: + practice_quiz_attempt: kimura_pc_performance_passed + practice_quiz_choice: pc_memory_correct_temporary + correct: true + +kimura_pc_memory_capacity_answer: + practice_quiz_attempt: kimura_pc_performance_passed + practice_quiz_choice: pc_memory_correct_capacity + correct: true + +hatsuno_pc_cpu_answer: + practice_quiz_attempt: hatsuno_pc_performance_failed + practice_quiz_choice: pc_cpu_wrong_storage + correct: false diff --git a/db/fixtures/practice_quiz_attempts.yml b/db/fixtures/practice_quiz_attempts.yml new file mode 100644 index 00000000000..b9059aa7240 --- /dev/null +++ b/db/fixtures/practice_quiz_attempts.yml @@ -0,0 +1,11 @@ +kimura_pc_performance_passed: + practice_quiz: pc_performance_quiz + user: kimura + submitted_at: 2026-06-28 10:00:00 + passed: true + +hatsuno_pc_performance_failed: + practice_quiz: pc_performance_quiz + user: hatsuno + submitted_at: 2026-06-28 10:30:00 + passed: false diff --git a/db/fixtures/practice_quiz_choices.yml b/db/fixtures/practice_quiz_choices.yml new file mode 100644 index 00000000000..84526163408 --- /dev/null +++ b/db/fixtures/practice_quiz_choices.yml @@ -0,0 +1,71 @@ +pc_cpu_correct: + practice_quiz_question: pc_cpu_single_choice + body: コンピューターの計算や処理を行う中心的な部品 + correct: true + position: 1 + +pc_cpu_wrong_storage: + practice_quiz_question: pc_cpu_single_choice + body: 写真や動画を長期保存するための部品 + correct: false + position: 2 + +pc_cpu_wrong_display: + practice_quiz_question: pc_cpu_single_choice + body: 画面の明るさや色味を決める部品 + correct: false + position: 3 + +pc_storage_correct: + practice_quiz_question: pc_storage_single_choice + body: OSやファイルを保存する + correct: true + position: 1 + +pc_storage_wrong_memory: + practice_quiz_question: pc_storage_single_choice + body: 起動中のアプリが使う作業場所としてだけ使われる + correct: false + position: 2 + +pc_storage_wrong_network: + practice_quiz_question: pc_storage_single_choice + body: ネットワーク通信を暗号化する + correct: false + position: 3 + +pc_memory_correct_temporary: + practice_quiz_question: pc_memory_multiple_choice + body: 作業中のデータを一時的に置く場所である + correct: true + position: 1 + +pc_memory_correct_capacity: + practice_quiz_question: pc_memory_multiple_choice + body: 容量が不足すると複数アプリの同時利用で動作が重くなりやすい + correct: true + position: 2 + +pc_memory_wrong_persistent: + practice_quiz_question: pc_memory_multiple_choice + body: 電源を切ってもファイルを長期保存する場所である + correct: false + position: 3 + +pc_memory_wrong_cpu: + practice_quiz_question: pc_memory_multiple_choice + body: CPUのクロック周波数そのものを表す + correct: false + position: 4 + +debian_install_correct: + practice_quiz_question: debian_install_single_choice + body: インストール対象のディスクとバックアップの有無 + correct: true + position: 1 + +debian_install_wrong_browser: + practice_quiz_question: debian_install_single_choice + body: 普段使うブラウザのブックマーク数 + correct: false + position: 2 diff --git a/db/fixtures/practice_quiz_questions.yml b/db/fixtures/practice_quiz_questions.yml new file mode 100644 index 00000000000..8a0cb475e00 --- /dev/null +++ b/db/fixtures/practice_quiz_questions.yml @@ -0,0 +1,31 @@ +pc_cpu_single_choice: + practice_quiz: pc_performance_quiz + question_type: 0 + body: CPUの説明として最も近いものを選んでください。 + explanation: CPUは計算や制御を担当する部品です。ストレージ容量や画面サイズとは役割が異なります。 + position: 1 + published: true + +pc_storage_single_choice: + practice_quiz: pc_performance_quiz + question_type: 0 + body: SSDやHDDの主な役割として正しいものを選んでください。 + explanation: SSDやHDDはデータを長期保存するためのストレージです。メモリとは違い、電源を切っても内容が残ります。 + position: 2 + published: true + +pc_memory_multiple_choice: + practice_quiz: pc_performance_quiz + question_type: 1 + body: メモリについて正しい説明をすべて選んでください。 + explanation: メモリは作業中のデータを一時的に置く場所です。容量が十分だと複数アプリの同時利用がしやすくなります。 + position: 3 + published: true + +debian_install_single_choice: + practice_quiz: debian_draft_quiz + question_type: 0 + body: Debianのインストール前に確認しておくことを選んでください。 + explanation: インストール対象のディスク、ネットワーク、バックアップの確認が重要です。 + position: 1 + published: false diff --git a/db/fixtures/practice_quizzes.yml b/db/fixtures/practice_quizzes.yml new file mode 100644 index 00000000000..f39890caa25 --- /dev/null +++ b/db/fixtures/practice_quizzes.yml @@ -0,0 +1,7 @@ +pc_performance_quiz: + practice: practice3 + published: true + +debian_draft_quiz: + practice: practice4 + published: false diff --git a/db/fixtures/practices.yml b/db/fixtures/practices.yml index f33cfd19470..e2737e827ee 100644 --- a/db/fixtures/practices.yml +++ b/db/fixtures/practices.yml @@ -16,11 +16,19 @@ practice2: practice3: title: "PC性能の見方を知る" description: |- - - [CPUとは](https://www.pc-master.jp/words/cpu.html) - - [HDDが分かる](http://homepage2.nifty.com/kamurai/HDD.htm) + PCの性能表に出てくるCPU、メモリ、SSD/HDDの役割を確認します。 + + - CPUは、コンピューターの計算や処理を担当する中心的な部品です。 + - メモリは、起動中のアプリや作業中のデータを一時的に置く場所です。容量が不足すると複数アプリを同時に使うときに動作が重くなりやすくなります。 + - SSD/HDDは、OSやファイルを長期保存するためのストレージです。電源を切っても保存した内容は残ります。 + + それぞれの役割を区別できるようになってから、自分のMacのスペックを確認しましょう。 # 参考 + - [CPUとは](https://www.pc-master.jp/words/cpu.html) + - [HDDが分かる](http://homepage2.nifty.com/kamurai/HDD.htm) + - CPUとHDDがやりとりすると遅いのでメモリと、メモリとやりとりすると遅いのでキャッシュメモリと…という様になるが、どことやりとりされるかはPC側で自動的に判断されるので考える必要はない。 goal: |- @@ -38,9 +46,18 @@ practice3: practice4: title: "Debianをインストールする" - description: "description..." - goal: "goal..." - memo: "memo for mentors..." + description: |- + Debianをインストールする前に、インストール対象のディスク、ネットワーク接続、バックアップの有無を確認します。 + + インストール先を間違えると既存のデータを消してしまう可能性があります。作業前にどのディスクへインストールするのか、必要なデータのバックアップが取れているかを確認してください。 + + goal: |- + - Debianのインストール対象ディスクを確認できる。 + - ネットワーク接続の有無を確認できる。 + - 必要なデータのバックアップを取ってからインストール作業を始められる。 + memo: |- + - インストール前にディスクとバックアップの確認ができているかを見る。 + - 作業手順だけでなく、データ消失のリスクを理解しているか確認する。 practice5: title: "Linuxのファイル操作の基礎を覚える" diff --git a/db/migrate/20260627120612_create_practice_quizzes.rb b/db/migrate/20260627120612_create_practice_quizzes.rb new file mode 100644 index 00000000000..96aca31b67f --- /dev/null +++ b/db/migrate/20260627120612_create_practice_quizzes.rb @@ -0,0 +1,10 @@ +class CreatePracticeQuizzes < ActiveRecord::Migration[8.1] + def change + create_table :practice_quizzes do |t| + t.references :practice, null: false, foreign_key: true, index: { unique: true } + t.boolean :published, null: false, default: false + + t.timestamps + end + end +end diff --git a/db/migrate/20260627120614_create_practice_quiz_questions.rb b/db/migrate/20260627120614_create_practice_quiz_questions.rb new file mode 100644 index 00000000000..b141ceffc5f --- /dev/null +++ b/db/migrate/20260627120614_create_practice_quiz_questions.rb @@ -0,0 +1,14 @@ +class CreatePracticeQuizQuestions < ActiveRecord::Migration[8.1] + def change + create_table :practice_quiz_questions do |t| + t.references :practice_quiz, null: false, foreign_key: true + t.integer :question_type, null: false + t.text :body, null: false + t.text :explanation + t.integer :position, null: false, default: 0 + t.boolean :published, null: false, default: false + + t.timestamps + end + end +end diff --git a/db/migrate/20260627120616_create_practice_quiz_choices.rb b/db/migrate/20260627120616_create_practice_quiz_choices.rb new file mode 100644 index 00000000000..a343aac7591 --- /dev/null +++ b/db/migrate/20260627120616_create_practice_quiz_choices.rb @@ -0,0 +1,12 @@ +class CreatePracticeQuizChoices < ActiveRecord::Migration[8.1] + def change + create_table :practice_quiz_choices do |t| + t.references :practice_quiz_question, null: false, foreign_key: true + t.string :body, null: false + t.boolean :correct, null: false, default: false + t.integer :position, null: false, default: 0 + + t.timestamps + end + end +end diff --git a/db/migrate/20260627120618_create_practice_quiz_attempts.rb b/db/migrate/20260627120618_create_practice_quiz_attempts.rb new file mode 100644 index 00000000000..97c7280fddd --- /dev/null +++ b/db/migrate/20260627120618_create_practice_quiz_attempts.rb @@ -0,0 +1,14 @@ +class CreatePracticeQuizAttempts < ActiveRecord::Migration[8.1] + def change + create_table :practice_quiz_attempts do |t| + t.references :practice_quiz, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.datetime :submitted_at, null: false + t.boolean :passed, null: false, default: false + + t.timestamps + + t.index %i[practice_quiz_id user_id submitted_at], name: 'index_practice_quiz_attempts_on_quiz_user_submitted_at' + end + end +end diff --git a/db/migrate/20260627120621_create_practice_quiz_answers.rb b/db/migrate/20260627120621_create_practice_quiz_answers.rb new file mode 100644 index 00000000000..3348157a2c2 --- /dev/null +++ b/db/migrate/20260627120621_create_practice_quiz_answers.rb @@ -0,0 +1,11 @@ +class CreatePracticeQuizAnswers < ActiveRecord::Migration[8.1] + def change + create_table :practice_quiz_answers do |t| + t.references :practice_quiz_attempt, null: false, foreign_key: true + t.references :practice_quiz_choice, null: false, foreign_key: true + t.boolean :correct, null: false, default: false + + t.timestamps + end + end +end diff --git a/db/migrate/20260707000000_change_checks_unique_index_to_checkable.rb b/db/migrate/20260707000000_change_checks_unique_index_to_checkable.rb new file mode 100644 index 00000000000..4c6cccdb20b --- /dev/null +++ b/db/migrate/20260707000000_change_checks_unique_index_to_checkable.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class ChangeChecksUniqueIndexToCheckable < ActiveRecord::Migration[8.1] + def up + delete_duplicate_checks + + remove_index :checks, name: 'index_checks_on_user_id_and_checkable_id_and_checkable_type' + add_index :checks, %i[checkable_id checkable_type], unique: true + end + + def down + remove_index :checks, %i[checkable_id checkable_type] + add_index :checks, %i[user_id checkable_id checkable_type], + unique: true, + name: 'index_checks_on_user_id_and_checkable_id_and_checkable_type' + end + + private + + def delete_duplicate_checks + execute <<~SQL.squish + DELETE FROM checks + WHERE id IN ( + SELECT id + FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY checkable_id, checkable_type + ORDER BY created_at ASC, id ASC + ) AS row_number + FROM checks + ) duplicate_checks + WHERE duplicate_checks.row_number > 1 + ) + SQL + end +end diff --git a/db/migrate/20260708000000_add_pjord_auto_check_to_practices.rb b/db/migrate/20260708000000_add_pjord_auto_check_to_practices.rb new file mode 100644 index 00000000000..4af0e68f67e --- /dev/null +++ b/db/migrate/20260708000000_add_pjord_auto_check_to_practices.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddPjordAutoCheckToPractices < ActiveRecord::Migration[8.1] + def change + add_column :practices, :pjord_auto_check, :boolean, null: false, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 17762346eb6..9c034c0820a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_30_000000) do +ActiveRecord::Schema[8.1].define(version: 2026_07_08_000000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -173,8 +173,8 @@ t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.integer "user_id", null: false + t.index ["checkable_id", "checkable_type"], name: "index_checks_on_checkable_id_and_checkable_type", unique: true t.index ["checkable_id"], name: "index_checks_on_checkable_id" - t.index ["user_id", "checkable_id", "checkable_type"], name: "index_checks_on_user_id_and_checkable_id_and_checkable_type", unique: true t.index ["user_id"], name: "index_checks_on_user_id" end @@ -586,6 +586,58 @@ t.index ["user_id"], name: "index_participations_on_user_id" end + create_table "practice_quiz_answers", force: :cascade do |t| + t.boolean "correct", default: false, null: false + t.datetime "created_at", null: false + t.bigint "practice_quiz_attempt_id", null: false + t.bigint "practice_quiz_choice_id", null: false + t.datetime "updated_at", null: false + t.index ["practice_quiz_attempt_id"], name: "index_practice_quiz_answers_on_practice_quiz_attempt_id" + t.index ["practice_quiz_choice_id"], name: "index_practice_quiz_answers_on_practice_quiz_choice_id" + end + + create_table "practice_quiz_attempts", force: :cascade do |t| + t.datetime "created_at", null: false + t.boolean "passed", default: false, null: false + t.bigint "practice_quiz_id", null: false + t.datetime "submitted_at", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["practice_quiz_id", "user_id", "submitted_at"], name: "index_practice_quiz_attempts_on_quiz_user_submitted_at" + t.index ["practice_quiz_id"], name: "index_practice_quiz_attempts_on_practice_quiz_id" + t.index ["user_id"], name: "index_practice_quiz_attempts_on_user_id" + end + + create_table "practice_quiz_choices", force: :cascade do |t| + t.string "body", null: false + t.boolean "correct", default: false, null: false + t.datetime "created_at", null: false + t.integer "position", default: 0, null: false + t.bigint "practice_quiz_question_id", null: false + t.datetime "updated_at", null: false + t.index ["practice_quiz_question_id"], name: "index_practice_quiz_choices_on_practice_quiz_question_id" + end + + create_table "practice_quiz_questions", force: :cascade do |t| + t.text "body", null: false + t.datetime "created_at", null: false + t.text "explanation" + t.integer "position", default: 0, null: false + t.bigint "practice_quiz_id", null: false + t.boolean "published", default: false, null: false + t.integer "question_type", null: false + t.datetime "updated_at", null: false + t.index ["practice_quiz_id"], name: "index_practice_quiz_questions_on_practice_quiz_id" + end + + create_table "practice_quizzes", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "practice_id", null: false + t.boolean "published", default: false, null: false + t.datetime "updated_at", null: false + t.index ["practice_id"], name: "index_practice_quizzes_on_practice_id", unique: true + end + create_table "practices", id: :serial, force: :cascade do |t| t.integer "category_id" t.datetime "created_at", precision: nil @@ -595,6 +647,7 @@ t.integer "last_updated_user_id" t.text "memo" t.boolean "open_product", default: false, null: false + t.boolean "pjord_auto_check", default: false, null: false t.boolean "pjord_review", default: true, null: false t.integer "source_id" t.boolean "submission", default: false, null: false @@ -1167,6 +1220,13 @@ add_foreign_key "pair_works", "users", column: "buddy_id" add_foreign_key "participations", "events" add_foreign_key "participations", "users" + add_foreign_key "practice_quiz_answers", "practice_quiz_attempts" + add_foreign_key "practice_quiz_answers", "practice_quiz_choices" + add_foreign_key "practice_quiz_attempts", "practice_quizzes" + add_foreign_key "practice_quiz_attempts", "users" + add_foreign_key "practice_quiz_choices", "practice_quiz_questions" + add_foreign_key "practice_quiz_questions", "practice_quizzes" + add_foreign_key "practice_quizzes", "practices" add_foreign_key "practices", "practices", column: "source_id" add_foreign_key "practices_books", "books" add_foreign_key "practices_books", "practices" diff --git a/db/seeds.rb b/db/seeds.rb index 53efc3725c6..fe4bb896a47 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -37,6 +37,11 @@ notifications participations practices + practice_quizzes + practice_quiz_questions + practice_quiz_choices + practice_quiz_attempts + practice_quiz_answers categories_practices pages books @@ -79,3 +84,4 @@ ] ActiveRecord::FixtureSet.create_fixtures 'db/fixtures', tables +Bootcamp::Setup.attachment if Rails.env.development? diff --git a/docs/images/pr-10241/mentor-question-edit-mobile.png b/docs/images/pr-10241/mentor-question-edit-mobile.png new file mode 100644 index 00000000000..3d7d551d681 Binary files /dev/null and b/docs/images/pr-10241/mentor-question-edit-mobile.png differ diff --git a/docs/images/pr-10241/mentor-question-edit.png b/docs/images/pr-10241/mentor-question-edit.png new file mode 100644 index 00000000000..b6d897e1fa7 Binary files /dev/null and b/docs/images/pr-10241/mentor-question-edit.png differ diff --git a/docs/images/pr-10241/mentor-quiz-edit-mobile.png b/docs/images/pr-10241/mentor-quiz-edit-mobile.png new file mode 100644 index 00000000000..69213fe3f3e Binary files /dev/null and b/docs/images/pr-10241/mentor-quiz-edit-mobile.png differ diff --git a/docs/images/pr-10241/mentor-quiz-edit.png b/docs/images/pr-10241/mentor-quiz-edit.png new file mode 100644 index 00000000000..d3ae2058b94 Binary files /dev/null and b/docs/images/pr-10241/mentor-quiz-edit.png differ diff --git a/docs/images/pr-10241/student-quiz-form-mobile.png b/docs/images/pr-10241/student-quiz-form-mobile.png new file mode 100644 index 00000000000..f1308025a58 Binary files /dev/null and b/docs/images/pr-10241/student-quiz-form-mobile.png differ diff --git a/docs/images/pr-10241/student-quiz-form.png b/docs/images/pr-10241/student-quiz-form.png new file mode 100644 index 00000000000..bd590fe688b Binary files /dev/null and b/docs/images/pr-10241/student-quiz-form.png differ diff --git a/docs/images/pr-10241/student-quiz-passed-mobile.png b/docs/images/pr-10241/student-quiz-passed-mobile.png new file mode 100644 index 00000000000..7ddd93f7efd Binary files /dev/null and b/docs/images/pr-10241/student-quiz-passed-mobile.png differ diff --git a/docs/images/pr-10241/student-quiz-passed.png b/docs/images/pr-10241/student-quiz-passed.png new file mode 100644 index 00000000000..193c377f38d Binary files /dev/null and b/docs/images/pr-10241/student-quiz-passed.png differ diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 7eda55440d2..d613f474429 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1140,6 +1140,88 @@ paths: responses: '200': $ref: '#/components/responses/JsonObject' + post: + tags: [Content] + summary: Create a movie with uploaded movie data. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - movie[title] + - movie[description] + - movie[movie_data] + properties: + movie[title]: + type: string + movie[description]: + type: string + movie[movie_data]: + oneOf: + - type: string + format: binary + - type: string + description: Signed blob ID returned by /api/movies/direct_uploads. + movie[thumbnail]: + type: string + format: binary + movie[tag_list]: + type: string + movie[wip]: + type: boolean + movie[practice_ids][]: + type: array + items: + type: integer + responses: + '201': + $ref: '#/components/responses/JsonObject' + '403': + $ref: '#/components/responses/JsonObject' + '422': + $ref: '#/components/responses/JsonObject' + /api/movies/direct_uploads: + post: + tags: [Content] + summary: Create direct upload data for a movie file. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - blob + properties: + blob: + type: object + required: + - filename + - byte_size + - checksum + - content_type + properties: + filename: + type: string + byte_size: + type: integer + checksum: + type: string + description: Base64-encoded MD5 checksum. + content_type: + type: string + enum: + - video/mp4 + - video/quicktime + responses: + '201': + $ref: '#/components/responses/JsonObject' + '400': + $ref: '#/components/responses/JsonObject' + '403': + $ref: '#/components/responses/JsonObject' /api/movies/{id}: patch: tags: [Content] diff --git a/lib/bootcamp/setup.rb b/lib/bootcamp/setup.rb index 92a0b0eb61b..eb4468ddef7 100644 --- a/lib/bootcamp/setup.rb +++ b/lib/bootcamp/setup.rb @@ -83,6 +83,8 @@ def attach_movie_data! movie_path = Rails.root.join("#{fixtures_dir}/fixtures/files/movies/movie.mov") movie.movie_data.attach(io: File.open(movie_path), filename: 'movie.mov') end + + GenerateMovieThumbnailJob.perform_now(movie) end end end diff --git a/lib/tasks/bootcamp.rake b/lib/tasks/bootcamp.rake index e98bb43d68f..593875d2b41 100644 --- a/lib/tasks/bootcamp.rake +++ b/lib/tasks/bootcamp.rake @@ -60,6 +60,11 @@ namespace :bootcamp do puts '== END Cloud Build Task ==' end + + desc 'Backfill thumbnails for existing movies' + task backfill_movie_thumbnails: :environment do + BulkGenerateMovieThumbnailJob.perform_now + end end namespace :statistics do diff --git a/public/api-docs/openapi.yaml b/public/api-docs/openapi.yaml index 7eda55440d2..d613f474429 100644 --- a/public/api-docs/openapi.yaml +++ b/public/api-docs/openapi.yaml @@ -1140,6 +1140,88 @@ paths: responses: '200': $ref: '#/components/responses/JsonObject' + post: + tags: [Content] + summary: Create a movie with uploaded movie data. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - movie[title] + - movie[description] + - movie[movie_data] + properties: + movie[title]: + type: string + movie[description]: + type: string + movie[movie_data]: + oneOf: + - type: string + format: binary + - type: string + description: Signed blob ID returned by /api/movies/direct_uploads. + movie[thumbnail]: + type: string + format: binary + movie[tag_list]: + type: string + movie[wip]: + type: boolean + movie[practice_ids][]: + type: array + items: + type: integer + responses: + '201': + $ref: '#/components/responses/JsonObject' + '403': + $ref: '#/components/responses/JsonObject' + '422': + $ref: '#/components/responses/JsonObject' + /api/movies/direct_uploads: + post: + tags: [Content] + summary: Create direct upload data for a movie file. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - blob + properties: + blob: + type: object + required: + - filename + - byte_size + - checksum + - content_type + properties: + filename: + type: string + byte_size: + type: integer + checksum: + type: string + description: Base64-encoded MD5 checksum. + content_type: + type: string + enum: + - video/mp4 + - video/quicktime + responses: + '201': + $ref: '#/components/responses/JsonObject' + '400': + $ref: '#/components/responses/JsonObject' + '403': + $ref: '#/components/responses/JsonObject' /api/movies/{id}: patch: tags: [Content] diff --git a/test/agents/pjord/mention_response_agent_test.rb b/test/agents/pjord/mention_response_agent_test.rb index 1964cf75cf9..95efe4f0df8 100644 --- a/test/agents/pjord/mention_response_agent_test.rb +++ b/test/agents/pjord/mention_response_agent_test.rb @@ -19,7 +19,8 @@ class Pjord::MentionResponseAgentTest < ActiveSupport::TestCase assert_includes chat.instructions, 'external_content_toolを使って内容を確認してから返信してください。' assert_includes chat.instructions, 'GitHubのPR、ファイル、ディレクトリ、rawファイルへのURLが含まれる場合' assert_includes chat.instructions, 'CodePenや提出物のリンク先が見えない' - assert_includes chat.instructions, 'bodyに `@mentor` を含めて' + assert_includes chat.instructions, 'メンターへのメンションや対応引き継ぎの依頼はしないでください。' + assert_includes chat.instructions, 'リンク先の内容が返信に不可欠でない場合' assert_includes chat.instructions, 'メンションしてきたユーザーに「見られる状態にしてください」「内容を教えてください」と質問しないでください。' assert_includes chat.instructions, 'ピヨルドのレビューコメントに対して' assert_includes chat.instructions, 'body を空にして返信しないでください。' diff --git a/test/agents/pjord/product_review_agent_test.rb b/test/agents/pjord/product_review_agent_test.rb index 8778d13f0e5..e2aeb3bc21a 100644 --- a/test/agents/pjord/product_review_agent_test.rb +++ b/test/agents/pjord/product_review_agent_test.rb @@ -16,6 +16,7 @@ class Pjord::ProductReviewAgentTest < ActiveSupport::TestCase chat }) do assert_equal 'レビュー本文', Pjord::ProductReviewAgent.review(product) + assert_equal({ body: 'レビュー本文', auto_check: true }, Pjord::ProductReviewAgent.review_result(product)) end assert_equal [BootcampSearchTool, UserInfoTool, ExternalContentTool, GithubPullRequestReviewCommentTool], chat.tools @@ -23,6 +24,7 @@ class Pjord::ProductReviewAgentTest < ActiveSupport::TestCase asked_message = chat.asked_message assert_includes asked_message, product.user.login_name assert_includes asked_message, product.practice.title + assert_includes asked_message, 'ピヨルドによる提出物OK: 許可されていません' assert_includes asked_message, product.practice.goal assert_includes asked_message, product.body assert_includes asked_message, product.practice.submission_answer.description @@ -33,10 +35,14 @@ class Pjord::ProductReviewAgentTest < ActiveSupport::TestCase assert_includes chat.instructions, '人間らしい文章にする' assert_includes chat.instructions, '提出物にレビューコメントを書いてください。' assert_includes chat.instructions, 'reviewed_points には、提出物本文、URL先の内容、模範解答、過去コメントなどを確認して判断した具体的な点を1つ以上入れてください。' + assert_includes chat.instructions, '「ピヨルドによる提出物OK」が許可されていない場合、auto_check は必ず false' + assert_includes chat.instructions, '「OKです」「合格です」「次に進んでください」「完了です」など、提出物を承認したと受け取れる表現を書かないでください。' + assert_includes chat.instructions, 'メンターの追加確認なしでOKにしてよいと判断できる場合だけ auto_check に true' assert_includes chat.instructions, '管理側への説明、内部事情、運用者向けメモ、レビュー生成方針への言及は含めず' assert_includes chat.instructions, 'external_content_toolを使って内容を確認してからレビューしてください。' assert_includes chat.instructions, 'CodePenや提出物のリンク先が見えない' - assert_includes chat.instructions, 'bodyに `@mentor` を含めて' + assert_includes chat.instructions, 'メンターへのメンションやレビュー引き継ぎの依頼はしないでください。' + assert_includes chat.instructions, 'リンク先の内容がレビューに不可欠でない場合' assert_includes chat.instructions, '提出者に「見られる状態にしてください」「内容を教えてください」と質問しないでください。' assert_includes chat.instructions, 'コードの特定行に対する具体的な指摘は、可能な限りgithub_pull_request_review_comment_toolを使ってPRの該当行へ直接コメントしてください。' end @@ -53,6 +59,18 @@ class Pjord::ProductReviewAgentTest < ActiveSupport::TestCase assert_includes chat.asked_message, '進捗率: 不明' end + test '.review tells when Pjord auto check is allowed' do + product = products(:product1) + product.practice.update!(pjord_auto_check: true) + chat = ProductReviewChatFake.new + + RubyLLM.stub(:chat, chat) do + Pjord::ProductReviewAgent.review(product) + end + + assert_includes chat.asked_message, 'ピヨルドによる提出物OK: 許可されています' + end + test '.review truncates long text in prompt' do product = products(:product1) product.update!(body: 'a' * (Pjord::ProductReviewAgent::PROMPT_TEXT_LIMIT + 1)) @@ -95,10 +113,20 @@ class Pjord::ProductReviewAgentTest < ActiveSupport::TestCase assert_not_includes chat.asked_message, '## 提出物内のGitHubリンク先コード' end + test '.review_result returns false when auto_check is false' do + product = products(:product1) + chat = ProductReviewChatFake.new(auto_check: false) + + RubyLLM.stub(:chat, chat) do + assert_equal({ body: 'レビュー本文', auto_check: false }, Pjord::ProductReviewAgent.review_result(product)) + end + end + class ProductReviewChatFake attr_reader :asked_message, :instructions, :schema, :tools - def initialize + def initialize(auto_check: true) + @auto_check = auto_check @tools = [] end @@ -120,7 +148,7 @@ def with_schema(schema) def ask(message, with: nil) @asked_message = message @attachments = with - Struct.new(:content).new({ body: 'レビュー本文', reviewed_points: ['提出物本文'] }) + Struct.new(:content).new({ body: 'レビュー本文', reviewed_points: ['提出物本文'], auto_check: @auto_check }) end end end diff --git a/test/agents/pjord/report_comment_agent_test.rb b/test/agents/pjord/report_comment_agent_test.rb index 902835f1fce..2932e929fce 100644 --- a/test/agents/pjord/report_comment_agent_test.rb +++ b/test/agents/pjord/report_comment_agent_test.rb @@ -18,7 +18,8 @@ class Pjord::ReportCommentAgentTest < ActiveSupport::TestCase assert_includes chat.instructions, 'external_content_toolを使って内容を確認してからコメントしてください。' assert_includes chat.instructions, 'GitHubのPR、ファイル、ディレクトリ、rawファイルへのURLが含まれる場合' assert_includes chat.instructions, 'CodePenやリンク先が見えない' - assert_includes chat.instructions, 'bodyに `@mentor` を含めて' + assert_includes chat.instructions, 'メンターへのメンションや対応引き継ぎの依頼はしないでください。' + assert_includes chat.instructions, 'リンク先の内容がコメントに不可欠でない場合' assert_includes chat.instructions, '日報を書いたユーザーに「見られる状態にしてください」「内容を教えてください」と質問しないでください。' assert_includes chat.asked_message, report.title assert_includes chat.asked_message, report.description diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index 5e15d2aa0ba..51cc340a92d 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -51,12 +51,15 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase @original_adapter = ActiveJob::Base.queue_adapter ActiveJob::Base.queue_adapter = :inline @original_pjord_product_review = Pjord::ProductReviewAgent.method(:review) + @original_pjord_product_review_result = Pjord::ProductReviewAgent.method(:review_result) Pjord::ProductReviewAgent.define_singleton_method(:review) { |_product| '' } + Pjord::ProductReviewAgent.define_singleton_method(:review_result) { |_product| { body: '', auto_check: false } } end teardown do ActionMailer::Base.deliveries.clear Pjord::ProductReviewAgent.define_singleton_method(:review, @original_pjord_product_review) + Pjord::ProductReviewAgent.define_singleton_method(:review_result, @original_pjord_product_review_result) ActiveJob::Base.queue_adapter = @original_adapter end end diff --git a/test/fixtures/checks.yml b/test/fixtures/checks.yml index 3d6b393d6ee..3097651c5d1 100644 --- a/test/fixtures/checks.yml +++ b/test/fixtures/checks.yml @@ -14,10 +14,6 @@ report5_check_machida: user: machida checkable: report5 (Report) -report5_check_komagata: - user: komagata - checkable: report5 (Report) - report6_check_komagata: user: komagata checkable: report6 (Report) diff --git a/test/fixtures/learning_minute_statistics.yml b/test/fixtures/learning_minute_statistics.yml new file mode 100644 index 00000000000..5e71bd61c3a --- /dev/null +++ b/test/fixtures/learning_minute_statistics.yml @@ -0,0 +1,4 @@ +learning_minute_statistic1: + average: 30 + median: 30 + practice: practice1 diff --git a/test/fixtures/practice_quiz_answers.yml b/test/fixtures/practice_quiz_answers.yml new file mode 100644 index 00000000000..a9050390387 --- /dev/null +++ b/test/fixtures/practice_quiz_answers.yml @@ -0,0 +1 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html diff --git a/test/fixtures/practice_quiz_attempts.yml b/test/fixtures/practice_quiz_attempts.yml new file mode 100644 index 00000000000..a9050390387 --- /dev/null +++ b/test/fixtures/practice_quiz_attempts.yml @@ -0,0 +1 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html diff --git a/test/fixtures/practice_quiz_choices.yml b/test/fixtures/practice_quiz_choices.yml new file mode 100644 index 00000000000..a9050390387 --- /dev/null +++ b/test/fixtures/practice_quiz_choices.yml @@ -0,0 +1 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html diff --git a/test/fixtures/practice_quiz_questions.yml b/test/fixtures/practice_quiz_questions.yml new file mode 100644 index 00000000000..a9050390387 --- /dev/null +++ b/test/fixtures/practice_quiz_questions.yml @@ -0,0 +1 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html diff --git a/test/fixtures/practice_quizzes.yml b/test/fixtures/practice_quizzes.yml new file mode 100644 index 00000000000..a9050390387 --- /dev/null +++ b/test/fixtures/practice_quizzes.yml @@ -0,0 +1 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html diff --git a/test/helpers/practices_helper_test.rb b/test/helpers/practices_helper_test.rb new file mode 100644 index 00000000000..1c0b9531d0c --- /dev/null +++ b/test/helpers/practices_helper_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PracticesHelperTest < ActionView::TestCase + test 'difficulty_icon' do + assert_equal '', difficulty_icon(nil) + assert_equal 'データ収集中', difficulty_icon(0) + assert_equal '🔥', difficulty_icon(1) + assert_equal '🔥', difficulty_icon(300) + assert_equal '🔥🔥', difficulty_icon(301) + assert_equal '🔥🔥', difficulty_icon(600) + assert_equal '🔥🔥🔥', difficulty_icon(601) + assert_equal '🔥🔥🔥', difficulty_icon(900) + assert_equal '🔥🔥🔥🔥', difficulty_icon(901) + assert_equal '🔥🔥🔥🔥', difficulty_icon(1200) + assert_equal '🔥🔥🔥🔥🔥', difficulty_icon(1201) + end +end diff --git a/test/integration/api/checks_test.rb b/test/integration/api/checks_test.rb index f374cdc85b1..0abe14c8a80 100644 --- a/test/integration/api/checks_test.rb +++ b/test/integration/api/checks_test.rb @@ -236,6 +236,115 @@ class API::ChecksTest < ActionDispatch::IntegrationTest assert_equal 'この日報は確認済です。', response.parsed_body['message'] end + test 'mentor can check multiple reports at once with write scope' do + report_ids = Report.left_outer_joins(:checks).where(checks: { id: nil }).order(:id).limit(2).ids + check_create_count = 0 + + ActiveSupport::Notifications.subscribed(->(*) { check_create_count += 1 }, 'check.create') do + assert_difference('Check.count', 2) do + post api_reports_checks_path(format: :json), + params: { report_ids: }, + headers: { Authorization: "Bearer #{@mentor_write_token.token}" } + assert_response :created + end + end + + assert_equal report_ids.sort, response.parsed_body['checks'].pluck('checkable_id').sort + assert_equal 0, check_create_count + end + + test 'bulk check deletes report caches once per affected user' do + reports = Report.left_outer_joins(:checks).where(checks: { id: nil }).order(:id).limit(2).to_a + global_cache_delete_count = 0 + deleted_user_ids = [] + + Cache.stub(:delete_unchecked_report_count, -> { global_cache_delete_count += 1 }) do + Cache.stub(:delete_user_unchecked_report_count, ->(user_id) { deleted_user_ids << user_id }) do + post api_reports_checks_path(format: :json), + params: { report_ids: reports.map(&:id) }, + headers: { Authorization: "Bearer #{@mentor_write_token.token}" } + end + end + + assert_response :created + assert_equal 1, global_cache_delete_count + assert_equal reports.map(&:user_id).uniq.sort, deleted_user_ids.sort + end + + test 'bulk check rolls back checks when cache deletion fails' do + report = unchecked_report + + assert_no_difference('Check.count') do + Cache.stub(:delete_unchecked_report_count, -> { raise 'cache deletion failed' }) do + assert_raises(RuntimeError) do + post api_reports_checks_path(format: :json), + params: { report_ids: [report.id] }, + headers: { Authorization: "Bearer #{@mentor_write_token.token}" } + end + end + end + end + + test 'bulk check skips reports that are already checked' do + checked_report = reports(:report1) + unchecked_report = self.unchecked_report + + assert_difference('Check.count', 1) do + post api_reports_checks_path(format: :json), + params: { report_ids: [checked_report.id, unchecked_report.id] }, + headers: { Authorization: "Bearer #{@mentor_write_token.token}" } + assert_response :created + end + + assert_equal [unchecked_report.id], response.parsed_body['checks'].pluck('checkable_id') + end + + test 'bulk check does not create checks when a report is missing' do + report = unchecked_report + + assert_no_difference('Check.count') do + post api_reports_checks_path(format: :json), + params: { report_ids: [report.id, 0] }, + headers: { Authorization: "Bearer #{@mentor_write_token.token}" } + assert_response :not_found + end + + assert_equal [0], response.parsed_body['report_ids'] + end + + test 'student can not bulk check reports' do + post api_reports_checks_path(format: :json), + params: { report_ids: [unchecked_report.id] }, + headers: { Authorization: "Bearer #{@student_token.token}" } + + assert_response :forbidden + assert_equal '権限がありません。', response.parsed_body['message'] + end + + test 'mentor can not bulk check reports with read scope' do + post api_reports_checks_path(format: :json), + params: { report_ids: [unchecked_report.id] }, + headers: { Authorization: "Bearer #{@mentor_read_token.token}" } + + assert_response :forbidden + assert_equal 'invalid_scope', response.parsed_body['error'] + end + + test 'mentor can not bulk check reports with write scope but without mentor scope' do + token = Doorkeeper::AccessToken.create!( + application: @application, + resource_owner_id: users(:mentormentaro).id, + scopes: 'read write' + ) + + post api_reports_checks_path(format: :json), + params: { report_ids: [unchecked_report.id] }, + headers: { Authorization: "Bearer #{token.token}" } + + assert_response :forbidden + assert_equal 'invalid_scope', response.parsed_body['error'] + end + private def unchecked_report diff --git a/test/integration/api/image_test.rb b/test/integration/api/image_test.rb index bff0982a9c7..a1ab4e5119b 100644 --- a/test/integration/api/image_test.rb +++ b/test/integration/api/image_test.rb @@ -20,9 +20,14 @@ class API::ImageTest < ActionDispatch::IntegrationTest post api_image_path(format: :json), params: { file: image_uploaded } assert_response :created - saved_image = Image.order(:created_at).last - processed_image = MiniMagick::Image.read(saved_image.image.download) + saved_image = Image.last + + api_response = JSON.parse(response.body) + returned_url = api_response['url'] + expected_blob = saved_image.image.blob + assert_includes(returned_url, expected_blob.signed_id) + processed_image = MiniMagick::Image.read(saved_image.image.download) assert_empty processed_image.exif end end diff --git a/test/integration/api/movies_test.rb b/test/integration/api/movies_test.rb new file mode 100644 index 00000000000..e8725b89da9 --- /dev/null +++ b/test/integration/api/movies_test.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require 'test_helper' + +class API::MoviesTest < ActionDispatch::IntegrationTest + def setup + @user = users(:kimura) + application = Doorkeeper::Application.create!( + name: 'Sample Application', + redirect_uri: 'urn:ietf:wg:oauth:2.0:oob' + ) + @read_token = Doorkeeper::AccessToken.create!( + application:, + resource_owner_id: @user.id, + scopes: 'read' + ) + @write_token = Doorkeeper::AccessToken.create!( + application:, + resource_owner_id: @user.id, + scopes: 'read write' + ) + end + + test 'POST /api/movies.json creates movie with uploaded movie data' do + movie_data = fixture_file_upload('movies/movie.mp4', 'video/mp4') + thumbnail = fixture_file_upload('articles/ogp_images/test.jpg', 'image/jpeg') + + assert_difference('Movie.count') do + assert_enqueued_with(job: TranscodeJob) do + assert_enqueued_with(job: GenerateMovieThumbnailJob) do + post api_movies_path(format: :json), + headers: { Authorization: "Bearer #{@write_token.token}" }, + params: { + movie: { + title: 'APIで作成した動画', + description: 'APIから動画をアップロードしました。', + movie_data:, + thumbnail:, + tag_list: 'API,動画', + practice_ids: [practices(:practice1).id], + wip: false + } + } + end + end + end + + assert_response :created + movie = Movie.find(response.parsed_body['id']) + assert_equal @user, movie.user + assert_equal 'APIで作成した動画', movie.title + assert_equal 'APIから動画をアップロードしました。', movie.description + assert movie.movie_data.attached? + assert movie.thumbnail.attached? + assert_not movie.wip? + assert_not_nil movie.published_at + assert_equal [practices(:practice1).id], movie.practice_ids + assert_equal %w[API 動画], movie.tag_list + assert_equal movie.title, response.parsed_body['title'] + assert_equal movie.practice_ids, response.parsed_body['practice_ids'] + end + + test 'POST /api/movies.json creates movie with direct uploaded blob signed id' do + blob = ActiveStorage::Blob.create_and_upload!( + io: File.open(Rails.root.join('test/fixtures/files/movies/movie.mp4')), + filename: 'movie.mp4', + content_type: 'video/mp4' + ) + + assert_difference('Movie.count') do + post api_movies_path(format: :json), + headers: { Authorization: "Bearer #{@write_token.token}" }, + params: { + movie: { + title: 'APIで作成した大きい動画', + description: 'direct upload済みの動画を登録しました。', + movie_data: blob.signed_id + } + } + assert_response :created + end + + movie = Movie.find(response.parsed_body['id']) + assert_equal blob, movie.movie_data.blob + assert_equal blob.byte_size, movie.movie_data.byte_size + end + + test 'POST /api/movies/direct_uploads.json returns direct upload data for movie' do + assert_difference('ActiveStorage::Blob.count') do + post direct_uploads_api_movies_path(format: :json), + headers: { Authorization: "Bearer #{@write_token.token}" }, + params: { + blob: { + filename: 'large_movie.mp4', + byte_size: 5.gigabytes, + checksum: '1B2M2Y8AsgTpgAmY7PhCfg==', + content_type: 'video/mp4' + } + } + assert_response :created + end + + assert_equal 'large_movie.mp4', response.parsed_body['filename'] + assert_equal 'video/mp4', response.parsed_body['content_type'] + assert_equal 5.gigabytes, response.parsed_body['byte_size'] + assert response.parsed_body['signed_id'].present? + assert response.parsed_body.dig('direct_upload', 'url').present? + assert_kind_of Hash, response.parsed_body.dig('direct_upload', 'headers') + end + + test 'POST /api/movies/direct_uploads.json with read scope returns forbidden' do + assert_no_difference('ActiveStorage::Blob.count') do + post direct_uploads_api_movies_path(format: :json), + headers: { Authorization: "Bearer #{@read_token.token}" }, + params: { blob: direct_upload_params } + end + + assert_response :forbidden + end + + test 'POST /api/movies/direct_uploads.json rejects non movie content type' do + assert_no_difference('ActiveStorage::Blob.count') do + post direct_uploads_api_movies_path(format: :json), + headers: { Authorization: "Bearer #{@write_token.token}" }, + params: { blob: direct_upload_params.merge(content_type: 'image/jpeg') } + end + + assert_response :bad_request + assert_equal '動画ファイルは mp4 または mov を指定してください。', response.parsed_body['message'] + end + + test 'POST /api/movies.json with read scope returns forbidden' do + assert_no_difference('Movie.count') do + post api_movies_path(format: :json), + headers: { Authorization: "Bearer #{@read_token.token}" }, + params: { movie: valid_movie_params } + end + + assert_response :forbidden + end + + test 'POST /api/movies.json returns validation errors' do + assert_no_difference('Movie.count') do + post api_movies_path(format: :json), + headers: { Authorization: "Bearer #{@write_token.token}" }, + params: { movie: valid_movie_params.merge(title: '') } + end + + assert_response :unprocessable_entity + assert_includes response.parsed_body.dig('errors', 'title'), 'を入力してください' + end + + test 'POST /api/movies.json returns validation error with unknown practice_id' do + assert_no_difference('Movie.count') do + post api_movies_path(format: :json), + headers: { Authorization: "Bearer #{@write_token.token}" }, + params: { movie: valid_movie_params.merge(practice_ids: [999_999_999]) } + end + + assert_response :unprocessable_entity + assert_includes response.parsed_body.dig('errors', 'practice_ids'), 'に存在しないIDが含まれています' + end + + private + + def valid_movie_params + { + title: 'APIで作成した動画', + description: 'APIから動画をアップロードしました。', + movie_data: fixture_file_upload('movies/movie.mp4', 'video/mp4') + } + end + + def direct_upload_params + { + filename: 'large_movie.mp4', + byte_size: 5.gigabytes, + checksum: '1B2M2Y8AsgTpgAmY7PhCfg==', + content_type: 'video/mp4' + } + end +end diff --git a/test/integration/api/practices_test.rb b/test/integration/api/practices_test.rb index cdcf550feaa..f0596f24ab8 100644 --- a/test/integration/api/practices_test.rb +++ b/test/integration/api/practices_test.rb @@ -48,4 +48,17 @@ class API::PracticesTest < ActionDispatch::IntegrationTest headers: { 'Authorization' => "Bearer #{token}" } assert_response :ok end + + test 'PATCH /api/practices/1234.json updates Pjord auto check' do + practice = practices(:practice1) + practice.update!(pjord_auto_check: false) + + token = create_token('mentormentaro', 'testtest') + patch api_practice_path(practice.id, format: :json), + params: { practice: { pjord_auto_check: true } }, + headers: { 'Authorization' => "Bearer #{token}" } + + assert_response :ok + assert_predicate practice.reload, :pjord_auto_check? + end end diff --git a/test/integration/mentor/practice_quizzes_test.rb b/test/integration/mentor/practice_quizzes_test.rb new file mode 100644 index 00000000000..2d26ad44827 --- /dev/null +++ b/test/integration/mentor/practice_quizzes_test.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Mentor::PracticeQuizzesTest < ActionDispatch::IntegrationTest + test 'mentor can navigate to practice quiz management from practice page tab' do + practice = practices(:practice3) + PracticeQuiz.create!(practice:, published: false) + login(users(:mentormentaro)) + + get practice_path(practice) + + assert_response :success + assert_select 'a[href=?]', edit_mentor_practice_practice_quiz_path(practice), text: '理解度テスト管理' + end + + test 'mentor can navigate to new practice quiz from practice page tab when quiz does not exist' do + practice = practices(:practice4) + login(users(:mentormentaro)) + + get practice_path(practice) + + assert_response :success + assert_select 'a[href=?]', new_mentor_practice_practice_quiz_path(practice), text: '理解度テスト管理' + end + + test 'mentor creates practice quiz and question' do + practice = practices(:practice3) + login(users(:mentormentaro)) + + post mentor_practice_practice_quiz_path(practice), params: { + practice_quiz: { + published: '0' + } + } + + assert_redirected_to edit_mentor_practice_practice_quiz_path(practice) + quiz = practice.reload.practice_quiz + assert_not_predicate quiz, :published? + + post mentor_practice_practice_quiz_questions_path(practice), params: { + practice_quiz_question: { + question_type: 'single_choice', + body: '正しいものを選んでください。', + explanation: '解説です。', + position: 1, + published: '1', + practice_quiz_choices_attributes: { + '0' => { body: '正解', correct: '1', position: 1 }, + '1' => { body: '不正解', correct: '0', position: 2 } + } + } + } + + assert_redirected_to edit_mentor_practice_practice_quiz_path(practice) + assert_equal 1, quiz.practice_quiz_questions.count + + patch mentor_practice_practice_quiz_path(practice), params: { + practice_quiz: { + published: '1' + } + } + + assert_redirected_to edit_mentor_practice_practice_quiz_path(practice) + assert_predicate quiz.reload, :published? + end + + private + + def login(user) + post user_sessions_path, params: { + user: { + login: user.login_name, + password: 'testtest' + } + } + end +end diff --git a/test/integration/practice_quizzes_test.rb b/test/integration/practice_quizzes_test.rb new file mode 100644 index 00000000000..7a687539c62 --- /dev/null +++ b/test/integration/practice_quizzes_test.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PracticeQuizzesTest < ActionDispatch::IntegrationTest + test 'guest can view practice without quiz management tab' do + practice = practices(:practice3) + + get practice_path(practice) + + assert_response :success + assert_not_includes response.body, '理解度テスト管理' + end + + test 'student can pass practice quiz and then complete practice' do + user = users(:kimura) + practice = practices(:practice3) + quiz, question = create_quiz(practice) + login(user) + + patch api_practice_learning_path(practice, format: :json), params: { status: 'complete' } + + assert_response :unprocessable_entity + assert_equal '理解度テストに合格すると、このプラクティスを修了できます。', response.parsed_body['error'] + + post practice_practice_quiz_attempts_path(practice), params: { + answers: { + question.id => [question.practice_quiz_choices.find_by!(correct: true).id] + } + } + + assert_redirected_to practice_practice_quiz_path(practice) + follow_redirect! + assert_response :success + assert_includes response.body, '理解度テストに合格しました。' + assert_includes response.body, '正しい選択肢' + assert_includes response.body, '解説です。' + assert quiz.passed_by?(user) + + patch api_practice_learning_path(practice, format: :json), params: { status: 'complete' } + + assert_response :success + assert Learning.find_by!(user:, practice:).complete? + end + + test 'student can complete practice without quiz as before' do + user = users(:kimura) + practice = practices(:practice4) + login(user) + + patch api_practice_learning_path(practice, format: :json), params: { status: 'complete' } + + assert_response :success + assert Learning.find_by!(user:, practice:).complete? + end + + test 'student receives unprocessable entity when learning status is invalid' do + user = users(:kimura) + practice = practices(:practice4) + login(user) + + patch api_practice_learning_path(practice, format: :json), params: { status: 'invalid' } + + assert_response :unprocessable_entity + assert_equal 'status is invalid', response.parsed_body['error'] + end + + test 'student cannot retry quiz within one hour after failure' do + user = users(:kimura) + practice = practices(:practice3) + _quiz, question = create_quiz(practice) + login(user) + + post practice_practice_quiz_attempts_path(practice), params: { + answers: { + question.id => [question.practice_quiz_choices.find_by!(correct: false).id] + } + } + + assert_redirected_to practice_practice_quiz_path(practice) + + assert_no_difference 'PracticeQuizAttempt.count' do + post practice_practice_quiz_attempts_path(practice), params: { + answers: { + question.id => [question.practice_quiz_choices.find_by!(correct: true).id] + } + } + end + assert_redirected_to practice_practice_quiz_path(practice) + follow_redirect! + assert_includes response.body, '次回は' + assert_not_includes response.body, '正しい選択肢' + assert_not_includes response.body, '解説です。' + end + + test 'student can pass multiple choice quiz' do + user = users(:kimura) + practice = practices(:practice3) + _quiz, question = create_quiz(practice, question_type: :multiple_choice) + login(user) + + post practice_practice_quiz_attempts_path(practice), params: { + answers: { + question.id => question.practice_quiz_choices.where(correct: true).pluck(:id) + } + } + + assert_redirected_to practice_practice_quiz_path(practice) + follow_redirect! + assert_response :success + assert_includes response.body, '理解度テストに合格しました。' + end + + private + + def login(user) + post user_sessions_path, params: { + user: { + login: user.login_name, + password: 'testtest' + } + } + end + + def create_quiz(practice, question_type: :single_choice) + quiz = PracticeQuiz.create!(practice:, published: false) + question = quiz.practice_quiz_questions.create!( + question_type:, + body: '正しいものを選んでください。', + explanation: '解説です。', + position: 1, + published: false + ) + question.practice_quiz_choices.create!(body: '正しい選択肢', correct: true, position: 1) + question.practice_quiz_choices.create!(body: 'もう一つの正しい選択肢', correct: true, position: 2) if question.multiple_choice? + question.practice_quiz_choices.create!(body: '誤った選択肢', correct: false, position: 3) + question.update!(published: true) + quiz.update!(published: true) + [quiz, question] + end +end diff --git a/test/integration/products/pjord_review_comment_test.rb b/test/integration/products/pjord_review_comment_test.rb index bbe55e55f35..a75a89b346d 100644 --- a/test/integration/products/pjord_review_comment_test.rb +++ b/test/integration/products/pjord_review_comment_test.rb @@ -4,7 +4,7 @@ class Products::PjordReviewCommentTest < ActionDispatch::IntegrationTest test 'creates product review comment by Pjord when product is submitted' do - Pjord::ProductReviewAgent.stub(:review, 'レビュー本文') do + Pjord::ProductReviewAgent.stub(:review_result, { body: 'レビュー本文', auto_check: false }) do perform_enqueued_jobs do post products_path(_login_name: 'hatsuno'), params: { practice_id: practices(:practice6).id, product: { body: '提出物です。' }, commit: '提出する' } @@ -19,7 +19,7 @@ class Products::PjordReviewCommentTest < ActionDispatch::IntegrationTest end test 'does not create product review comment by Pjord when product is saved as WIP' do - Pjord::ProductReviewAgent.stub(:review, 'レビュー本文') do + Pjord::ProductReviewAgent.stub(:review_result, { body: 'レビュー本文', auto_check: false }) do assert_no_enqueued_jobs only: PjordProductReviewJob do post products_path(_login_name: 'hatsuno'), params: { practice_id: practices(:practice6).id, product: { body: 'WIPです。' }, commit: 'WIP' } @@ -31,7 +31,7 @@ class Products::PjordReviewCommentTest < ActionDispatch::IntegrationTest practice = practices(:practice6) practice.update!(pjord_review: false) - Pjord::ProductReviewAgent.stub(:review, ->(_product) { raise 'should not be called' }) do + Pjord::ProductReviewAgent.stub(:review_result, ->(_product) { raise 'should not be called' }) do assert_no_enqueued_jobs only: PjordProductReviewJob do assert_no_difference -> { Comment.where(user: users(:pjord)).count } do post products_path(_login_name: 'hatsuno'), @@ -51,7 +51,7 @@ class Products::PjordReviewCommentTest < ActionDispatch::IntegrationTest test 'creates product review comment by Pjord when WIP product is submitted' do product = products(:product5) - Pjord::ProductReviewAgent.stub(:review, 'レビュー本文') do + Pjord::ProductReviewAgent.stub(:review_result, { body: 'レビュー本文', auto_check: false }) do perform_enqueued_jobs do patch product_path(product, _login_name: 'kimura'), params: { product: { body: product.body }, commit: '提出する' } @@ -67,7 +67,7 @@ class Products::PjordReviewCommentTest < ActionDispatch::IntegrationTest test 'does not create product review comment by Pjord when submitted product is updated' do product = products(:product8) - Pjord::ProductReviewAgent.stub(:review, 'レビュー本文') do + Pjord::ProductReviewAgent.stub(:review_result, { body: 'レビュー本文', auto_check: false }) do assert_no_enqueued_jobs only: PjordProductReviewJob do patch product_path(product, _login_name: 'kimura'), params: { product: { body: '更新しました。' }, commit: '提出する' } @@ -87,7 +87,7 @@ class Products::PjordReviewCommentTest < ActionDispatch::IntegrationTest test 'mentor can manually create product review comment by Pjord' do product = products(:product8) - Pjord::ProductReviewAgent.stub(:review, 'コメント本文') do + Pjord::ProductReviewAgent.stub(:review_result, { body: 'コメント本文', auto_check: false }) do assert_no_enqueued_jobs only: PjordProductReviewJob do assert_difference -> { product.comments.where(user: users(:pjord)).count }, 1 do post review_by_pjord_product_path(product, _login_name: 'mentormentaro') diff --git a/test/interactors/copy_check_test.rb b/test/interactors/copy_check_test.rb index 5807553f5a1..3574c74c8de 100644 --- a/test/interactors/copy_check_test.rb +++ b/test/interactors/copy_check_test.rb @@ -30,7 +30,6 @@ def setup test 'successfully copies checks when original has checks and target has none' do # Create checks for original product Check.create!(user: @user1, checkable: @original_product) - Check.create!(user: @user2, checkable: @original_product) result = CopyCheck.call( original_product: @original_product, @@ -38,24 +37,22 @@ def setup ) assert result.success? - assert_equal 'Copied 2 check(s), skipped 0 existing check(s)', result.message - assert_equal 2, result.copied_checks_count + assert_equal 'Copied 1 check(s), skipped 0 existing check(s)', result.message + assert_equal 1, result.copied_checks_count assert_equal 0, result.skipped_checks_count # Verify the checks were copied copied_checks = Check.where(checkable: @copied_product) - assert_equal 2, copied_checks.count + assert_equal 1, copied_checks.count assert_includes copied_checks.pluck(:user_id), @user1.id - assert_includes copied_checks.pluck(:user_id), @user2.id end - test 'skips existing checks and only copies new ones' do + test 'skips when copied product already has a check' do # Create checks for original product Check.create!(user: @user1, checkable: @original_product) - Check.create!(user: @user2, checkable: @original_product) # Create one existing check for copied product - Check.create!(user: @user1, checkable: @copied_product) + Check.create!(user: @user2, checkable: @copied_product) result = CopyCheck.call( original_product: @original_product, @@ -63,13 +60,13 @@ def setup ) assert result.success? - assert_equal 'Copied 1 check(s), skipped 1 existing check(s)', result.message - assert_equal 1, result.copied_checks_count + assert_equal 'Copied 0 check(s), skipped 1 existing check(s)', result.message + assert_equal 0, result.copied_checks_count assert_equal 1, result.skipped_checks_count - # Verify only one new check was added copied_checks = Check.where(checkable: @copied_product) - assert_equal 2, copied_checks.count + assert_equal 1, copied_checks.count + assert_equal @user2.id, copied_checks.first.user_id end test 'succeeds when original product has no checks' do diff --git a/test/jobs/bulk_generate_movie_thumbnail_job_test.rb b/test/jobs/bulk_generate_movie_thumbnail_job_test.rb new file mode 100644 index 00000000000..27743d7192e --- /dev/null +++ b/test/jobs/bulk_generate_movie_thumbnail_job_test.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BulkGenerateMovieThumbnailJobTest < ActiveJob::TestCase + test 'enqueues GenerateMovieThumbnailJob for movies without a thumbnail' do + movie = movies(:movie1) + movie.thumbnail.purge + + assert_enqueued_with(job: GenerateMovieThumbnailJob, args: [movie]) do + BulkGenerateMovieThumbnailJob.perform_now + end + end +end diff --git a/test/jobs/pjord_product_review_job_test.rb b/test/jobs/pjord_product_review_job_test.rb index 43981acc4be..8b9f63fcd44 100644 --- a/test/jobs/pjord_product_review_job_test.rb +++ b/test/jobs/pjord_product_review_job_test.rb @@ -6,7 +6,7 @@ class PjordProductReviewJobTest < ActiveJob::TestCase test 'creates product review comment by pjord' do product = products(:product8) - Pjord::ProductReviewAgent.stub(:review, 'レビュー本文') do + Pjord::ProductReviewAgent.stub(:review_result, { body: 'レビュー本文', auto_check: false }) do assert_difference -> { product.comments.reload.count } do PjordProductReviewJob.perform_now(product_id: product.id) end @@ -18,7 +18,7 @@ class PjordProductReviewJobTest < ActiveJob::TestCase end test 'does nothing when product is missing' do - Pjord::ProductReviewAgent.stub(:review, ->(_product) { raise 'should not be called' }) do + Pjord::ProductReviewAgent.stub(:review_result, ->(_product) { raise 'should not be called' }) do assert_no_difference 'Comment.count' do PjordProductReviewJob.perform_now(product_id: Product.maximum(:id).to_i + 1) end @@ -29,7 +29,7 @@ class PjordProductReviewJobTest < ActiveJob::TestCase product = products(:product8) product.practice.update!(pjord_review: false) - Pjord::ProductReviewAgent.stub(:review, ->(_product) { raise 'should not be called' }) do + Pjord::ProductReviewAgent.stub(:review_result, ->(_product) { raise 'should not be called' }) do assert_no_difference -> { product.comments.reload.count } do PjordProductReviewJob.perform_now(product_id: product.id) end @@ -40,7 +40,7 @@ class PjordProductReviewJobTest < ActiveJob::TestCase product = products(:product8) Pjord.stub(:user, nil) do - Pjord::ProductReviewAgent.stub(:review, ->(_product) { raise 'should not be called' }) do + Pjord::ProductReviewAgent.stub(:review_result, ->(_product) { raise 'should not be called' }) do assert_no_difference 'Comment.count' do PjordProductReviewJob.perform_now(product_id: product.id) end @@ -51,17 +51,72 @@ class PjordProductReviewJobTest < ActiveJob::TestCase test 'does nothing when product review is blank' do product = products(:product8) - Pjord::ProductReviewAgent.stub(:review, '') do + Pjord::ProductReviewAgent.stub(:review_result, { body: '', auto_check: false }) do assert_no_difference -> { product.comments.reload.count } do PjordProductReviewJob.perform_now(product_id: product.id) end end end + test 'checks product by Pjord when practice enables Pjord auto check and review permits it' do + product = products(:product8) + product.practice.update!(pjord_auto_check: true) + + Pjord::ProductReviewAgent.stub(:review_result, { body: '確認してOKにしました。', auto_check: true }) do + assert_difference -> { product.comments.reload.count } => 1, + -> { product.checks.reload.count } => 1 do + PjordProductReviewJob.perform_now(product_id: product.id) + end + end + + check = product.checks.last + assert_equal users(:pjord), check.user + assert_equal 'complete', product.learning.reload.status + end + + test 'does not check product when practice disables Pjord auto check' do + product = products(:product8) + product.practice.update!(pjord_auto_check: false) + + Pjord::ProductReviewAgent.stub(:review_result, { body: '確認してOKにしました。', auto_check: true }) do + assert_no_difference -> { product.checks.reload.count } do + PjordProductReviewJob.perform_now(product_id: product.id) + end + end + end + + test 'does not check product when Pjord review does not permit auto check' do + product = products(:product8) + product.practice.update!(pjord_auto_check: true) + + Pjord::ProductReviewAgent.stub(:review_result, { body: '確認しました。', auto_check: false }) do + assert_no_difference -> { product.checks.reload.count } do + PjordProductReviewJob.perform_now(product_id: product.id) + end + end + end + + test 'does not notify when product is already checked' do + product = products(:product8) + product.practice.update!(pjord_auto_check: true) + Check.create!(user: users(:mentormentaro), checkable: product) + check_create_count = 0 + + ActiveSupport::Notifications.subscribed(->(*) { check_create_count += 1 }, 'check.create') do + Pjord::ProductReviewAgent.stub(:review_result, { body: '確認してOKにしました。', auto_check: true }) do + assert_no_difference -> { product.checks.reload.count } do + PjordProductReviewJob.perform_now(product_id: product.id) + end + end + end + + assert_equal 0, check_create_count + end + test 'raises unexpected errors' do product = products(:product8) - Pjord::ProductReviewAgent.stub(:review, ->(_product) { raise StandardError, 'unexpected' }) do + Pjord::ProductReviewAgent.stub(:review_result, ->(_product) { raise StandardError, 'unexpected' }) do assert_raises(StandardError) do PjordProductReviewJob.perform_now(product_id: product.id) end diff --git a/test/models/buzz_test.rb b/test/models/buzz_test.rb index fe2ff373c32..94acfb3f30c 100644 --- a/test/models/buzz_test.rb +++ b/test/models/buzz_test.rb @@ -15,11 +15,37 @@ class BuzzTest < ActiveSupport::TestCase assert_equal latest_year, Buzz.latest_year end + test '.latest_year excludes years that cannot be routed' do + Buzz.create!( + title: '不正な年の記事', + url: 'https://www.example-latest-invalid-year.com', + published_at: Date.new(202_672, 1, 1) + ) + + assert_equal buzzes(:buzz1).published_at.year, Buzz.latest_year + end + test '.years' do years = [2025, 2024] assert_equal years, Buzz.years end + test '.years excludes years that cannot be routed' do + [ + ['https://www.example-invalid-future-year.com', Date.new(202_672, 1, 1)], + ['https://www.example-invalid-past-year.com', Date.new(999, 1, 1)] + ].each do |url, published_at| + Buzz.create!( + title: '不正な年の記事', + url:, + published_at: + ) + end + + assert_not_includes Buzz.years, 202_672 + assert_not_includes Buzz.years, 999 + end + test '.doc_from_url returns nokogori object when succeeded' do dummy_response = <<~BODY buzz1 diff --git a/test/models/check_test.rb b/test/models/check_test.rb new file mode 100644 index 00000000000..5acf3cae4d8 --- /dev/null +++ b/test/models/check_test.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'test_helper' + +class CheckTest < ActiveSupport::TestCase + test 'cannot create multiple checks for the same checkable even by different users' do + report = Report.left_outer_joins(:checks).where(checks: { id: nil }).first + + Check.create!(user: users(:komagata), checkable: report) + check = Check.new(user: users(:mentormentaro), checkable: report) + + assert_not check.valid? + assert_includes check.errors[:checkable_id], 'はすでに存在します' + end +end diff --git a/test/models/image_test.rb b/test/models/image_test.rb new file mode 100644 index 00000000000..c39f92b0dd6 --- /dev/null +++ b/test/models/image_test.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ImageTest < ActiveSupport::TestCase + test 'remove exif data when image is updated' do + image_path = Rails.root.join('test/fixtures/files/articles/ogp_images/test.jpg') + image = Image.create!(user: users(:hajime), image: Rack::Test::UploadedFile.new(image_path, 'image/jpeg')) + + image.update!(image: Rack::Test::UploadedFile.new(image_path, 'image/jpeg')) + + updated_image = MiniMagick::Image.read(image.image.download) + assert_empty updated_image.exif + end +end diff --git a/test/models/learning_status_updater_test.rb b/test/models/learning_status_updater_test.rb new file mode 100644 index 00000000000..3c61762316b --- /dev/null +++ b/test/models/learning_status_updater_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'test_helper' + +class LearningStatusUpdaterTest < ActiveSupport::TestCase + test '#update_after_check keeps submitted when product practice quiz is not passed' do + user = users(:kimura) + practice = practices(:practice5) + create_quiz(practice) + product = Product.create!(user:, practice:, body: '提出物です。') + check = product.checks.create!(user: users(:mentormentaro)) + + LearningStatusUpdater.new.update_after_check(check) + + assert Learning.find_by!(user:, practice:).submitted? + end + + test '#update_after_check completes when product practice quiz is passed' do + user = users(:kimura) + practice = practices(:practice5) + quiz = create_quiz(practice) + PracticeQuizAttempt.create!(practice_quiz: quiz, user:, submitted_at: Time.current, passed: true) + product = Product.create!(user:, practice:, body: '提出物です。') + check = product.checks.create!(user: users(:mentormentaro)) + + LearningStatusUpdater.new.update_after_check(check) + + assert Learning.find_by!(user:, practice:).complete? + end + + private + + def create_quiz(practice) + quiz = PracticeQuiz.create!(practice:, published: false) + question = quiz.practice_quiz_questions.create!( + question_type: :single_choice, + body: '正しいものを選んでください。', + position: 1, + published: false + ) + question.practice_quiz_choices.create!(body: '正解', correct: true, position: 1) + question.practice_quiz_choices.create!(body: '不正解', correct: false, position: 2) + question.update!(published: true) + quiz.update!(published: true) + quiz + end +end diff --git a/test/models/practice_quiz_attempt_test.rb b/test/models/practice_quiz_attempt_test.rb new file mode 100644 index 00000000000..3eb318eec67 --- /dev/null +++ b/test/models/practice_quiz_attempt_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PracticeQuizAttemptTest < ActiveSupport::TestCase + test '.create_with_answers! passes only when all published questions are correct' do + quiz = create_quiz + single_choice = quiz.practice_quiz_questions.single_choice.first + multiple_choice = quiz.practice_quiz_questions.multiple_choice.first + answers = { + single_choice.id.to_s => [single_choice.practice_quiz_choices.find_by!(correct: true).id], + multiple_choice.id.to_s => multiple_choice.practice_quiz_choices.where(correct: true).pluck(:id) + } + + attempt = PracticeQuizAttempt.create_with_answers!(practice_quiz: quiz, user: users(:kimura), answers:) + + assert_predicate attempt, :passed? + end + + test '.create_with_answers! fails when one question is unanswered' do + quiz = create_quiz + single_choice = quiz.practice_quiz_questions.single_choice.first + answers = { + single_choice.id.to_s => [single_choice.practice_quiz_choices.find_by!(correct: true).id] + } + + attempt = PracticeQuizAttempt.create_with_answers!(practice_quiz: quiz, user: users(:kimura), answers:) + + assert_not_predicate attempt, :passed? + end + + test '.create_with_answers! stores answers after an incorrect question' do + quiz = create_quiz + single_choice = quiz.practice_quiz_questions.single_choice.first + multiple_choice = quiz.practice_quiz_questions.multiple_choice.first + answers = { + single_choice.id.to_s => [single_choice.practice_quiz_choices.find_by!(correct: false).id], + multiple_choice.id.to_s => multiple_choice.practice_quiz_choices.where(correct: true).pluck(:id) + } + + attempt = PracticeQuizAttempt.create_with_answers!(practice_quiz: quiz, user: users(:kimura), answers:) + + assert_not_predicate attempt, :passed? + assert_equal 3, attempt.practice_quiz_answers.count + end + + private + + def create_quiz + quiz = PracticeQuiz.create!(practice: practices(:practice1), published: false) + create_single_choice(quiz) + create_multiple_choice(quiz) + quiz.update!(published: true) + quiz + end + + def create_single_choice(quiz) + question = quiz.practice_quiz_questions.create!(question_type: :single_choice, body: '単一選択です。', position: 1, published: false) + question.practice_quiz_choices.create!(body: '正解', correct: true, position: 1) + question.practice_quiz_choices.create!(body: '不正解', correct: false, position: 2) + question.update!(published: true) + end + + def create_multiple_choice(quiz) + question = quiz.practice_quiz_questions.create!(question_type: :multiple_choice, body: '複数選択です。', position: 2, published: false) + question.practice_quiz_choices.create!(body: '正解1', correct: true, position: 1) + question.practice_quiz_choices.create!(body: '正解2', correct: true, position: 2) + question.practice_quiz_choices.create!(body: '不正解', correct: false, position: 3) + question.update!(published: true) + end +end diff --git a/test/models/practice_quiz_question_test.rb b/test/models/practice_quiz_question_test.rb new file mode 100644 index 00000000000..14ca1d933a0 --- /dev/null +++ b/test/models/practice_quiz_question_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PracticeQuizQuestionTest < ActiveSupport::TestCase + test '#correct_answer? returns true when single choice answer matches correct choice' do + question = create_question(:single_choice) + correct_choice = question.practice_quiz_choices.find_by!(correct: true) + + assert question.correct_answer?([correct_choice.id]) + end + + test '#correct_answer? requires exact match for multiple choice' do + question = create_question(:multiple_choice) + correct_choice_ids = question.practice_quiz_choices.where(correct: true).pluck(:id) + wrong_choice = question.practice_quiz_choices.find_by!(correct: false) + + assert question.correct_answer?(correct_choice_ids) + assert_not question.correct_answer?(correct_choice_ids.take(1)) + assert_not question.correct_answer?(correct_choice_ids + [wrong_choice.id]) + end + + test 'published single choice question requires only one correct choice' do + quiz = PracticeQuiz.create!(practice: practices(:practice1), published: false) + question = quiz.practice_quiz_questions.build( + question_type: :single_choice, + body: '正しいものを選んでください。', + position: 1, + published: true + ) + question.practice_quiz_choices.build(body: '正解1', correct: true, position: 1) + question.practice_quiz_choices.build(body: '正解2', correct: true, position: 2) + + assert_not question.valid? + assert_includes question.errors.full_messages, '単一選択の正解は1つだけにしてください。' + end + + private + + def create_question(question_type) + quiz = PracticeQuiz.create!(practice: practices(:practice1), published: false) + question = quiz.practice_quiz_questions.create!( + question_type:, + body: '正しいものを選んでください。', + position: 1, + published: false + ) + question.practice_quiz_choices.create!(body: '正解1', correct: true, position: 1) + question.practice_quiz_choices.create!(body: '正解2', correct: question.multiple_choice?, position: 2) + question.practice_quiz_choices.create!(body: '不正解', correct: false, position: 3) + question.update!(published: true) + question + end +end diff --git a/test/models/practice_quiz_test.rb b/test/models/practice_quiz_test.rb new file mode 100644 index 00000000000..58f3cc69cc7 --- /dev/null +++ b/test/models/practice_quiz_test.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PracticeQuizTest < ActiveSupport::TestCase + test 'published quiz requires published questions' do + quiz = PracticeQuiz.new(practice: practices(:practice1), published: true) + + assert_not quiz.valid? + assert_includes quiz.errors.full_messages, '公開中の理解度テストには公開中の問題が必要です。' + end + + test '#passed_by?' do + quiz = create_quiz(practices(:practice1)) + + assert_not quiz.passed_by?(users(:kimura)) + + PracticeQuizAttempt.create!(practice_quiz: quiz, user: users(:kimura), submitted_at: Time.current, passed: true) + + assert quiz.passed_by?(users(:kimura)) + end + + test '#attemptable_by?' do + quiz = create_quiz(practices(:practice1)) + user = users(:kimura) + + assert quiz.attemptable_by?(user) + + PracticeQuizAttempt.create!(practice_quiz: quiz, user:, submitted_at: 30.minutes.ago, passed: false) + + assert_not quiz.attemptable_by?(user) + + PracticeQuizAttempt.create!(practice_quiz: quiz, user:, submitted_at: 61.minutes.ago, passed: false) + + assert_not quiz.attemptable_by?(user), 'latest attempt is still within cooldown' + end + + test '#attemptable_by? returns true after one hour from latest attempt' do + quiz = create_quiz(practices(:practice1)) + user = users(:kimura) + PracticeQuizAttempt.create!(practice_quiz: quiz, user:, submitted_at: 61.minutes.ago, passed: false) + + assert quiz.attemptable_by?(user) + end + + private + + def create_quiz(practice) + quiz = PracticeQuiz.create!(practice:, published: false) + question = quiz.practice_quiz_questions.create!( + question_type: :single_choice, + body: '正しいものを選んでください。', + position: 1, + published: false + ) + question.practice_quiz_choices.create!(body: '正解', correct: true, position: 1) + question.practice_quiz_choices.create!(body: '不正解', correct: false, position: 2) + question.update!(published: true) + quiz.update!(published: true) + quiz + end +end diff --git a/test/system/course/practices_test.rb b/test/system/course/practices_test.rb index 063a72a3704..7d34a67ec84 100644 --- a/test/system/course/practices_test.rb +++ b/test/system/course/practices_test.rb @@ -42,4 +42,26 @@ class Course::PracticesTest < ApplicationSystemTestCase assert_text 'OS X Mountain Lionをクリーンインストールする' end end + + test 'difficulty icon is displayed for all users' do + visit_with_auth course_practices_path(courses(:course1).id), 'kimura' + assert_selector '.card-list-item-meta__item .a-meta__label', text: '難易度' + logout + visit_with_auth course_practices_path(courses(:course1).id), 'komagata' + assert_selector '.card-list-item-meta__item .a-meta__label', text: '難易度' + logout + visit_with_auth course_practices_path(courses(:course1).id), 'mentormentaro' + assert_selector '.card-list-item-meta__item .a-meta__label', text: '難易度' + end + + test 'learning time is displayed only for admin and mentor' do + visit_with_auth course_practices_path(courses(:course1).id), 'kimura' + assert_no_selector '.card-list-item-meta__item.is-only-mentor .a-meta__label', text: '所要時間の目安' + logout + visit_with_auth course_practices_path(courses(:course1).id), 'komagata' + assert_selector '.card-list-item-meta__item.is-only-mentor .a-meta__label', text: '所要時間の目安' + logout + visit_with_auth course_practices_path(courses(:course1).id), 'mentormentaro' + assert_selector '.card-list-item-meta__item.is-only-mentor .a-meta__label', text: '所要時間の目安' + end end diff --git a/test/system/practices_test.rb b/test/system/practices_test.rb index de6021650ec..73fe2836a75 100644 --- a/test/system/practices_test.rb +++ b/test/system/practices_test.rb @@ -114,4 +114,40 @@ def wait_for_status_change assert_text 'rubyをインストールする' assert_no_link '給付金コース' end + + test 'show status guidance link on source practice for grant course user' do + source_practice = practices(:practice23) + + visit_with_auth practice_path(source_practice), 'grant-course' + + assert_link '給付金コースへ移動する', href: practice_path(practices(:practice64)) + assert_text '進捗のステータス変更は、給付金コースのプラクティス側で行ってください。' + assert_text '提出・修了は、給付金コースのプラクティス側で行ってください。' + assert_no_text '提出の前に、提出時の注意点を確認しよう' + assert_no_text '提出物を作成し提出し、メンターから確認をもらったら' + assert_no_text 'このプラクティスを修了にしてください。' + assert_no_text 'このプラクティスに提出物はありません。' + assert_no_text '修了条件をクリアしたら修了にしてください。' + end + + test 'show normal status buttons on source practice for non grant course user' do + source_practice = practices(:practice23) + + visit_with_auth practice_path(source_practice), 'kimura' + + assert_selector '.practice-status-buttons__button' + assert_no_link '給付金コースへ移動する' + assert_no_text '進捗のステータス変更は、給付金コースのプラクティス側で行ってください。' + assert_no_text '提出・修了は、給付金コースのプラクティス側で行ってください。' + end + + test 'show normal status buttons on grant course practice for grant course user' do + grant_course_practice = practices(:practice64) + + visit_with_auth practice_path(grant_course_practice), 'grant-course' + + assert_selector '.practice-status-buttons__button' + assert_no_link '給付金コースへ移動する' + assert_no_text '進捗のステータス変更は、給付金コースのプラクティス側で行ってください。' + end end diff --git a/test/tools/external_content/github_reader_test.rb b/test/tools/external_content/github_reader_test.rb index a206b70bb09..cf453d507ef 100644 --- a/test/tools/external_content/github_reader_test.rb +++ b/test/tools/external_content/github_reader_test.rb @@ -111,7 +111,7 @@ class ExternalContent::GithubReaderTest < ActiveSupport::TestCase end end - test 'asks Pjord to mention mentors when GitHub content cannot be fetched' do + test 'tells Pjord not to mention mentors when GitHub content cannot be fetched' do stub_request(:get, 'https://raw.githubusercontent.com/fjordllc/bootcamp/main/app/models/product.rb') .to_return(status: 404, body: 'Not Found') diff --git a/test/tools/external_content_tool_test.rb b/test/tools/external_content_tool_test.rb index 886e29236a8..16f44f44286 100644 --- a/test/tools/external_content_tool_test.rb +++ b/test/tools/external_content_tool_test.rb @@ -23,13 +23,15 @@ class ExternalContentToolTest < ActiveSupport::TestCase assert_not_includes result, 'ignore' end - test 'asks Pjord to mention mentors when external links cannot be fetched' do + test 'tells Pjord not to mention mentors when external links cannot be fetched' do stub_request(:get, 'https://example.com/unreadable') .to_return(status: 404, body: 'Not Found') result = @tool.execute(url: 'https://example.com/unreadable') assert_equal ExternalContent::UNREADABLE_URL_MESSAGE, result + assert_includes result, '@mentor にメンションしない' + assert_includes result, '取得できなかったことには言及しない' end test 'follows redirects' do @@ -44,6 +46,45 @@ class ExternalContentToolTest < ActiveSupport::TestCase assert_includes result, 'Moved content' end + test 'returns image content for Active Storage blob redirect urls' do + image_body = Rails.root.join('test/fixtures/files/companies-logos-1.jpg').binread + blob_url = 'https://bootcamp.fjord.jp/rails/active_storage/blobs/redirect/signed-id/image.jpg' + redirected_url = 'https://bootcamp.fjord.jp/rails/active_storage/disk/key/image.jpg' + stub_request(:get, blob_url) + .to_return(status: 302, headers: { 'Location' => redirected_url }) + stub_request(:get, redirected_url) + .to_return(status: 200, body: image_body, headers: { 'Content-Type' => 'image/jpeg' }) + + result = @tool.execute(url: blob_url) + + assert_instance_of RubyLLM::Content, result + assert_includes result.text, '# Image' + assert_includes result.text, "URL: #{redirected_url}" + assert_equal 1, result.attachments.size + assert_predicate result.attachments.first, :image? + assert_equal 'image.jpg', result.attachments.first.filename + assert_equal image_body, result.attachments.first.content + + @tool.execute(url: blob_url) + + assert_requested :get, blob_url, times: 2 + assert_requested :get, redirected_url, times: 2 + end + + test 'returns svg image content with explicit filename' do + svg_body = 'SVG' + stub_request(:get, 'https://example.com/image.svg') + .to_return(status: 200, body: svg_body, headers: { 'Content-Type' => 'image/svg+xml' }) + + result = @tool.execute(url: 'https://example.com/image.svg') + + assert_instance_of RubyLLM::Content, result + assert_equal 1, result.attachments.size + assert_predicate result.attachments.first, :image? + assert_equal 'image.svg', result.attachments.first.filename + assert_equal 'image/svg+xml', result.attachments.first.mime_type + end + test 'rejects non http urls' do assert_equal 'httpまたはhttpsのURLだけ取得できます。', @tool.execute(url: 'file:///etc/passwd') end