diff --git a/.circleci/config.yml b/.circleci/config.yml index 230702c40e8..fb973053171 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -125,7 +125,7 @@ jobs: - run: name: Test command: | - circleci tests glob "test/**/*_test.rb" | timeout 20m circleci tests run --command="xargs bundle exec rails test" --verbose --split-by=timings --timings-type=filename + circleci tests glob "test/**/*_test.rb" | grep -v '^test/visual/' | timeout 20m circleci tests run --command="xargs bundle exec rails test" --verbose --split-by=timings --timings-type=filename - store_test_results: path: test/reports - store_artifacts: diff --git a/.cloudbuild/cloudbuild-task.yaml b/.cloudbuild/cloudbuild-task.yaml index 72c39e4e29a..4b4aa2f3353 100644 --- a/.cloudbuild/cloudbuild-task.yaml +++ b/.cloudbuild/cloudbuild-task.yaml @@ -25,6 +25,15 @@ steps: - id: Task name: asia.gcr.io/$PROJECT_ID/$REPO_NAME:$COMMIT_SHA automapSubstitutions: true + env: + - RAILS_ENV=production + - APP_HOST_NAME=bootcamp-staging.fjord.jp + - SECRET_KEY_BASE=dummy + - DB_HOST=/cloudsql/$_CLOUD_SQL_HOST + - DB_NAME=$_DB_NAME + - DB_USER=$_DB_USER + - DB_PASS=$_DB_PASS + - OPEN_AI_ACCESS_TOKEN=$_OPEN_AI_ACCESS_TOKEN waitFor: - Build volumes: @@ -34,7 +43,6 @@ steps: - bash - "-c" - |- - eval "$(.cloudbuild/cloud-build-env exports)" bin/rails bootcamp:oneshot:cloudbuild - id: Kill_SqlProxy name: gcr.io/cloud-builders/docker @@ -50,7 +58,7 @@ options: substitutionOption: ALLOW_LOOSE automapSubstitutions: true substitutions: - _APP_HOST_NAME: _ + _APP_HOST_NAME: bootcamp-staging.fjord.jp _CLOUD_SQL_HOST: _ _DB_NAME: _ _DB_PASS: _ 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..55883800ba2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -222,10 +222,10 @@ jobs: uses: actions/checkout@v4 - name: Generate flakewatch HTML - # komagata/flakewatch v0.6.31 - uses: komagata/flakewatch@e734ba749bb1f8de2b9ae8a35b2cb02d63bbbf76 + # komagata/flakewatch v0.6.32 + uses: komagata/flakewatch@v0.6.32 with: junit-artifact-pattern: junit-xml-* source-root: ${{ github.workspace }} history-branch: flakewatch-data - history-write: auto + history-write: false diff --git a/.gitignore b/.gitignore index dd79f1bb921..8f3cd3e6fe0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,12 @@ vendor/bundle/ +# Visual regression: commit baseline PNGs, ignore transient diff/latest output. +test/visual/screenshots/**/*.diff.png +test/visual/screenshots/**/*.latest.png +test/visual/screenshots/**/*.committed.png +test/visual/screenshots/**/.attempts/ + /node_modules /yarn-error.log 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/Gemfile b/Gemfile index b32f32705ca..d896757f569 100644 --- a/Gemfile +++ b/Gemfile @@ -119,6 +119,7 @@ end group :test do gem 'capybara' gem 'capybara-playwright-driver', '~> 0.5.9' + gem 'capybara-screenshot-diff', require: false gem 'minitest', '< 6.0' gem 'minitest-ci' gem 'minitest-retry' diff --git a/Gemfile.lock b/Gemfile.lock index cce2cfadd3e..4a71ff2dc2e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -136,6 +136,8 @@ GEM addressable capybara playwright-ruby-client (>= 1.16.0) + capybara-screenshot-diff (1.12.0) + capybara (>= 2, < 4) childprocess (5.1.0) logger (~> 1.5) cocooned (2.5.0) @@ -814,6 +816,7 @@ DEPENDENCIES byebug capybara capybara-playwright-driver (~> 0.5.9) + capybara-screenshot-diff cocooned concurrent-ruby (= 1.3.4) countries (>= 5.5.0) 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..667c0c557a1 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -16,7 +16,6 @@ @import "./application/blocks/admin/_embedding-status.css"; @import "./application/blocks/auth-form/_auth-form-nav.css"; @import "./application/blocks/auth-form/_auth-form.css"; -@import "./application/blocks/auth-form/_next-settlement-time.css"; @import "./application/blocks/auth-form/_skip-practices.css"; @import "./application/blocks/cards/_card-counts.css"; @import "./application/blocks/cards/_card-body-main-actions.css"; @@ -39,7 +38,6 @@ @import "./application/blocks/header/_header.css"; @import "./application/blocks/mention/_mention.css"; @import "./application/blocks/modal/_modal-celebrate-report-count-body.css"; -@import "./application/blocks/page/_o-page-info.css"; @import "./application/blocks/page/_page-body-actions.css"; @import "./application/blocks/page/_page-body-header.css"; @import "./application/blocks/page/_page-body.css"; @@ -47,7 +45,6 @@ @import "./application/blocks/page/_page-header-actions.css"; @import "./application/blocks/page/_page-main-header-actions.css"; @import "./application/blocks/page/_page-main-header.css"; -@import "./application/blocks/page/_page-notices.css"; @import "./application/blocks/page/_page-optional-header.css"; @import "./application/blocks/page/_page-tabs.css"; @import "./application/blocks/page/_page.css"; @@ -63,8 +60,6 @@ @import "./application/blocks/pair-work/_pair-work-schedule-dates.css"; @import "./application/blocks/practice/_categories.css"; @import "./application/blocks/practice/_category-practices-item.css"; -@import "./application/blocks/practice/_completion-massage.css"; -@import "./application/blocks/practice/_practice-content-actions.css"; @import "./application/blocks/practice/_practice-content.css"; @import "./application/blocks/practice/_practice-contents.css"; @import "./application/blocks/practice/_practice-first-actions.css"; @@ -79,14 +74,13 @@ @import "./application/blocks/side/_side-tabs.css"; @import "./application/blocks/side/_user-statuses.css"; @import "./application/blocks/static-pages/_thanks-message.css"; -@import "./application/blocks/static-pages/_thanks.css"; @import "./application/blocks/survey/_survey-added-question.css"; @import "./application/blocks/survey/_survey-additional-question.css"; @import "./application/blocks/survey/_survey-questions-item.css"; @import "./application/blocks/survey/_survey-questions.css"; -@import "./application/blocks/survey/survey-answer.css"; -@import "./application/blocks/survey/survey-result.css"; -@import "./application/blocks/survey/survey-answers.css"; +@import "./application/blocks/survey/_survey-answer.css"; +@import "./application/blocks/survey/_survey-result.css"; +@import "./application/blocks/survey/_survey-answers.css"; @import "./application/blocks/tags/_random-tags.css"; @import "./application/blocks/tags/_tag-links.css"; @import "./application/blocks/tags/_tag-input.css"; @@ -94,10 +88,8 @@ @import "./application/blocks/thread/_thread-comment.css"; @import "./application/blocks/thread/_thread-comments-more.css"; @import "./application/blocks/thread/_thread-comments.css"; -@import "./application/blocks/thread/_thread-form.css"; @import "./application/blocks/thread/_thread-header.css"; @import "./application/blocks/thread/_action-completed.css"; -@import "./application/blocks/user/_active-practices-list.css"; @import "./application/blocks/user/_companies-item.css"; @import "./application/blocks/user/_completed-practices-progress.css"; @import "./application/blocks/user/_following.css"; @@ -109,7 +101,6 @@ @import "./application/blocks/user/_user-group.css"; @import "./application/blocks/user/_user-metas.css"; @import "./application/blocks/user/_user-profile.css"; -@import "./application/blocks/user/_user-secret-attributes.css"; @import "./application/blocks/user/_users-item.css"; @import "./application/blocks/user/_user-study-streak-tracker.css"; @import "./application/blocks/coding-test/_code-editor.css"; diff --git a/app/assets/stylesheets/application/blocks/auth-form/_auth-form.css b/app/assets/stylesheets/application/blocks/auth-form/_auth-form.css index 2aefc4e3c5b..e9d5861d408 100644 --- a/app/assets/stylesheets/application/blocks/auth-form/_auth-form.css +++ b/app/assets/stylesheets/application/blocks/auth-form/_auth-form.css @@ -1,4 +1,3 @@ - .auth-form-logo-image { display: block; } @@ -60,9 +59,6 @@ padding: 1rem 1.75rem; } -.auth-form__body { -} - @media (width >= 48em) { .auth-form__body { padding: 1.5rem 1.75rem; @@ -86,10 +82,6 @@ font-weight: 700; } -.auth-form-items { - margin-bottom: 1.25rem; -} - .auth-form__alert { padding: .5rem 1.5rem; background-color: var(--danger); diff --git a/app/assets/stylesheets/application/blocks/auth-form/_next-settlement-time.css b/app/assets/stylesheets/application/blocks/auth-form/_next-settlement-time.css deleted file mode 100644 index bfaa580b6a2..00000000000 --- a/app/assets/stylesheets/application/blocks/auth-form/_next-settlement-time.css +++ /dev/null @@ -1,28 +0,0 @@ - -.next-settlement-time { - @media (width >= 48em) { - display: flex; - align-items: center; - justify-content: center; - - } -} - -.next-settlement-time__title { - font-size: .875rem; - line-height: 1.4; - font-weight: 400; - - @media (width >= 48em) { - margin-right: .5em; - - } -} - -.next-settlement-time__time { - font-size: 1rem; - line-height: 1.4; - font-weight: 700; - color: var(--danger); - -} diff --git a/app/assets/stylesheets/application/blocks/cards/_card-counts.css b/app/assets/stylesheets/application/blocks/cards/_card-counts.css index fe3b3210c87..d79c2d2773b 100644 --- a/app/assets/stylesheets/application/blocks/cards/_card-counts.css +++ b/app/assets/stylesheets/application/blocks/cards/_card-counts.css @@ -1,7 +1,3 @@ - -.card-counts { -} - @media (width >= 48em) { .card-counts.is-user { margin-bottom: 1.5rem; diff --git a/app/assets/stylesheets/application/blocks/company/_company-profile.css b/app/assets/stylesheets/application/blocks/company/_company-profile.css index ea4ae544971..8fb6eb2a3fd 100644 --- a/app/assets/stylesheets/application/blocks/company/_company-profile.css +++ b/app/assets/stylesheets/application/blocks/company/_company-profile.css @@ -1,29 +1,3 @@ -.company-profile__header { - padding: 1rem; -} - -.company-profile__header .company-links__items { - justify-content: flex-start; -} - -.company-profile__header-inner { - text-align: center; -} - -.company-profile__header-start { - margin-right: 1.25rem; -} - -.company-profile__header-end { - flex: 1; -} - -.company-profile__logo { - position: static; - text-align: center; - margin-bottom: 0.25rem; -} - .company-profile__logo-image { background-color: var(--base); object-fit: contain; @@ -32,13 +6,6 @@ height: 3.5rem; } -.company-profile__name { - font-weight: 800; - color: var(--main-text); - font-size: 1.25rem; - line-height: 1.45; -} - .company-profile__body { padding: 1rem; } @@ -48,25 +15,11 @@ } @media (width >= 48em) { - .company-profile__header-inner { - display: flex; - text-align: left; - } - - .company-profile__logo { - margin-bottom: 0; - text-align: left; - } - .company-profile__logo-image { width: 4.5rem; height: 4.5rem; } - .company-profile__name { - font-size: 1.5rem; - } - .company-profile__description { font-size: 0.9375rem; } diff --git a/app/assets/stylesheets/application/blocks/event/_event-meta.css b/app/assets/stylesheets/application/blocks/event/_event-meta.css index 323cb3029f7..dae2271bfb0 100644 --- a/app/assets/stylesheets/application/blocks/event/_event-meta.css +++ b/app/assets/stylesheets/application/blocks/event/_event-meta.css @@ -1,7 +1,3 @@ - -.event-meta { -} - @media (width >= 48em) { .event-meta { font-size: .8125rem; @@ -14,9 +10,6 @@ } } -.event-meta__inner { -} - @media (width >= 48em) { .event-meta__inner { padding: .75rem 1.5rem; diff --git a/app/assets/stylesheets/application/blocks/footer/_footer-nav.css b/app/assets/stylesheets/application/blocks/footer/_footer-nav.css index db1a28cf421..156424a28d7 100644 --- a/app/assets/stylesheets/application/blocks/footer/_footer-nav.css +++ b/app/assets/stylesheets/application/blocks/footer/_footer-nav.css @@ -1,4 +1,3 @@ - .footer-nav__items { display: flex; line-height: 1.4; @@ -20,9 +19,6 @@ } } -.footer-nav__item { -} - @media (width <= 47.9375em) { .footer-nav__item { flex: 0 0 50%; diff --git a/app/assets/stylesheets/application/blocks/header/_header-dropdown.css b/app/assets/stylesheets/application/blocks/header/_header-dropdown.css index 7dee77063dc..449d7d129b0 100644 --- a/app/assets/stylesheets/application/blocks/header/_header-dropdown.css +++ b/app/assets/stylesheets/application/blocks/header/_header-dropdown.css @@ -256,7 +256,6 @@ input:checked + .header-dropdown .header-dropdown__background { } } - @media (width <= 47.9375em) { .header-dropdown__inner.is-notification .pill-nav__item-link.is-active { background-color: var(--base); diff --git a/app/assets/stylesheets/application/blocks/header/_header.css b/app/assets/stylesheets/application/blocks/header/_header.css index 3cb47d242a5..af025658141 100644 --- a/app/assets/stylesheets/application/blocks/header/_header.css +++ b/app/assets/stylesheets/application/blocks/header/_header.css @@ -51,9 +51,6 @@ opacity: .8; } -.header__title-image { -} - @media (width >= 48em) { .header__title-image { height: 2.25rem; @@ -65,25 +62,3 @@ height: 1.75rem; } } - -.header-show-mobile-nav { - display: flex; - cursor: pointer; - height: 100%; - justify-content: center; - align-items: center; - width: 3rem; - position: relative; -} - -.header-show-mobile-nav-items { - display: none; -} - -@media (width <= 47.9375em) { - .header-show-mobile-nav-items { - display: flex; - height: 100%; - margin-right: -1rem; - } -} diff --git a/app/assets/stylesheets/application/blocks/micro-report/_micro-report-layout.css b/app/assets/stylesheets/application/blocks/micro-report/_micro-report-layout.css index 09a3d176aec..9de0ac2044d 100644 --- a/app/assets/stylesheets/application/blocks/micro-report/_micro-report-layout.css +++ b/app/assets/stylesheets/application/blocks/micro-report/_micro-report-layout.css @@ -155,47 +155,6 @@ border-right: 1px solid var(--border); } -/* ============================================= - Bottom nav (mobile only) -============================================= */ - -.mr-bottom-nav { - display: none; - position: fixed; - bottom: 0; - left: 0; - right: 0; - background-color: var(--background); - border-top: 1px solid var(--border); - z-index: 100; -} - -.mr-bottom-nav__items { - list-style: none; - padding: 0; - margin: 0; - display: flex; -} - -.mr-bottom-nav__item { - flex: 1; -} - -.mr-bottom-nav__link { - display: flex; - align-items: center; - justify-content: center; - height: 3.25rem; - font-size: 1.25rem; - color: var(--default-text); - text-decoration: none; - transition: color 0.2s; -} - -.mr-bottom-nav__link:hover { - color: var(--main); -} - /* ============================================= Responsive ============================================= */ @@ -214,8 +173,4 @@ border-right: none; padding-bottom: 3.25rem; } - - .mr-bottom-nav { - display: block; - } } diff --git a/app/assets/stylesheets/application/blocks/micro-report/_micro-report.css b/app/assets/stylesheets/application/blocks/micro-report/_micro-report.css index 1b7475ff872..2b616552430 100644 --- a/app/assets/stylesheets/application/blocks/micro-report/_micro-report.css +++ b/app/assets/stylesheets/application/blocks/micro-report/_micro-report.css @@ -1,5 +1,3 @@ - - .micro-report { position: relative; display: flex; @@ -63,9 +61,6 @@ } } -.micro-report__title-user-icon { -} - @media (width >= 48em) { .micro-report__title-user-icon { display: none; @@ -82,9 +77,6 @@ } } -.micro-report__title-link { -} - @media (width >= 48em) { .micro-report__title-link { height: 100%; @@ -127,9 +119,6 @@ margin-right: .1875rem; } -.micro-report__body { -} - @media (width <= 47.9375em) { .micro-report__body { overflow-wrap: break-word; diff --git a/app/assets/stylesheets/application/blocks/micro-report/_micro-reports.css b/app/assets/stylesheets/application/blocks/micro-report/_micro-reports.css index 44445a24b25..16bfb5d05c2 100644 --- a/app/assets/stylesheets/application/blocks/micro-report/_micro-reports.css +++ b/app/assets/stylesheets/application/blocks/micro-report/_micro-reports.css @@ -18,7 +18,3 @@ .micro-reports-with-form { padding-bottom: 8.75rem; } - -.micro-reports-without-form { - padding-bottom: 0; -} diff --git a/app/assets/stylesheets/application/blocks/page-content/_page-content-header-metas.css b/app/assets/stylesheets/application/blocks/page-content/_page-content-header-metas.css index 2aec0983b0f..84980fac249 100644 --- a/app/assets/stylesheets/application/blocks/page-content/_page-content-header-metas.css +++ b/app/assets/stylesheets/application/blocks/page-content/_page-content-header-metas.css @@ -1,7 +1,3 @@ - -.page-content-header-metas { -} - @media (width >= 48em) { .page-content-header-metas { display: flex; @@ -15,9 +11,6 @@ flex-wrap: wrap; } -.page-content-header-metas__meta { -} - .page-content-header-metas__meta:not(:last-child) { margin-right: .75em; } diff --git a/app/assets/stylesheets/application/blocks/page-content/_page-content-header.css b/app/assets/stylesheets/application/blocks/page-content/_page-content-header.css index fe46637ccc6..7578dafca91 100644 --- a/app/assets/stylesheets/application/blocks/page-content/_page-content-header.css +++ b/app/assets/stylesheets/application/blocks/page-content/_page-content-header.css @@ -1,4 +1,3 @@ - .page-content-header { position: relative; } @@ -53,9 +52,6 @@ } } -.page-content-header__user { -} - @media (width <= 47.9375em) { .page-content-header__user { display: flex; @@ -63,9 +59,6 @@ } } -.page-content-header__category { -} - @media (width <= 47.9375em) { .page-content-header__category { display: flex; @@ -164,12 +157,6 @@ } } -.page-content-header__before-title .a-user-name { -} - -.page-content-header__before-title .a-meta { -} - .page-content-header__before-title .a-category-link { font-size: .75rem; } @@ -245,9 +232,6 @@ margin-top: .75rem; } -.page-content-header__row { -} - .page-content-header__row:not(:last-child) { margin-bottom: .5rem; } @@ -256,9 +240,6 @@ margin-bottom: 1rem; } -.page-content-header__description { -} - @media (width >= 48em) { .page-content-header__description { padding-block: .25rem; diff --git a/app/assets/stylesheets/application/blocks/page-content/_page-content.css b/app/assets/stylesheets/application/blocks/page-content/_page-content.css index 81a5cb3e4ef..eadc7046dc8 100644 --- a/app/assets/stylesheets/application/blocks/page-content/_page-content.css +++ b/app/assets/stylesheets/application/blocks/page-content/_page-content.css @@ -1,16 +1,3 @@ - -.page-content { -} - -.page-content.is-products { -} - -.page-content.is-questions { -} - -.page-content.is-books { -} - .page-content.is-users { width: 100%; } diff --git a/app/assets/stylesheets/application/blocks/page/_o-page-info.css b/app/assets/stylesheets/application/blocks/page/_o-page-info.css deleted file mode 100644 index 09394a7ad0b..00000000000 --- a/app/assets/stylesheets/application/blocks/page/_o-page-info.css +++ /dev/null @@ -1,7 +0,0 @@ -.o-page-info { - background-color: var(--success-tint); - font-size: 0.875rem; - padding-block: 0.75em; - color: var(--success-text); - border-bottom: solid 1px var(--success); -} diff --git a/app/assets/stylesheets/application/blocks/page/_page-body.css b/app/assets/stylesheets/application/blocks/page/_page-body.css index 89e17be3268..5a1f732a2ce 100644 --- a/app/assets/stylesheets/application/blocks/page/_page-body.css +++ b/app/assets/stylesheets/application/blocks/page/_page-body.css @@ -1,4 +1,3 @@ - .page-body { padding-block: 1.5rem; } @@ -55,9 +54,6 @@ width: 100%; } -.page-body__columns { -} - @media (width >= 64em) { .page-body__columns { display: flex; @@ -88,9 +84,6 @@ } } -.page-body__column { -} - .page-body__column.is-main { flex: 1; margin-inline: auto; @@ -104,9 +97,6 @@ flex: 0 0 14rem; } -.page-body__description { -} - @media (width >= 48em) { .page-body__description { font-size: .875rem; @@ -190,9 +180,6 @@ color: var(--warning); } -.page-body__description .message.alert { -} - .page-body__description .message.danger { background-color: var(--danger-tint); color: var(--danger-text); diff --git a/app/assets/stylesheets/application/blocks/page/_page-header-actions.css b/app/assets/stylesheets/application/blocks/page/_page-header-actions.css index fa2040f8ad9..c3cf93fc664 100644 --- a/app/assets/stylesheets/application/blocks/page/_page-header-actions.css +++ b/app/assets/stylesheets/application/blocks/page/_page-header-actions.css @@ -1,6 +1,3 @@ -.page-header-actions { -} - @media (width <= 47.9375em) { .page-header-actions { margin-inline: -1rem; diff --git a/app/assets/stylesheets/application/blocks/page/_page-header.css b/app/assets/stylesheets/application/blocks/page/_page-header.css index 69b9ac4111d..ca896f94e3e 100644 --- a/app/assets/stylesheets/application/blocks/page/_page-header.css +++ b/app/assets/stylesheets/application/blocks/page/_page-header.css @@ -1,4 +1,3 @@ - .page-header { background-color: var(--base); } @@ -85,9 +84,6 @@ align-self: center; } -.page-header__action { -} - @media (width <= 47.9375em) { .page-header__action { display: none; diff --git a/app/assets/stylesheets/application/blocks/page/_page-main-header-actions.css b/app/assets/stylesheets/application/blocks/page/_page-main-header-actions.css index ce29df4bb98..7ceb638e1a0 100644 --- a/app/assets/stylesheets/application/blocks/page/_page-main-header-actions.css +++ b/app/assets/stylesheets/application/blocks/page/_page-main-header-actions.css @@ -1,13 +1,9 @@ - .page-main-header-actions__items { display: flex; gap: .5rem; justify-content: center; } -.page-main-header-actions__item { -} - @media (width >= 48em) { .page-main-header-actions__item { min-width: 10rem; diff --git a/app/assets/stylesheets/application/blocks/page/_page-notices.css b/app/assets/stylesheets/application/blocks/page/_page-notices.css deleted file mode 100644 index 8f4c5b38280..00000000000 --- a/app/assets/stylesheets/application/blocks/page/_page-notices.css +++ /dev/null @@ -1,12 +0,0 @@ -.page-notices { - margin-block: -0.5rem 1rem; - background-color: var(--success); -} - -.page-notices__item .a-badge { - min-width: 5rem; -} - -.page-notices__item:not(:first-child) { - border-top: dashed 1px rgb(255 255 255 / 20%); -} diff --git a/app/assets/stylesheets/application/blocks/page/_page-tabs.css b/app/assets/stylesheets/application/blocks/page/_page-tabs.css index 8db0eef628f..54b61d9c0bc 100644 --- a/app/assets/stylesheets/application/blocks/page/_page-tabs.css +++ b/app/assets/stylesheets/application/blocks/page/_page-tabs.css @@ -1,4 +1,3 @@ - .page-tabs { background-color: var(--base); border-bottom: solid 1px var(--border); @@ -13,9 +12,6 @@ padding-inline: 1rem; } -.page-tabs__item { -} - @media (width >= 48em) { .page-tabs__item { min-width: 8.5rem; 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..e297f99b05b 100644 --- a/app/assets/stylesheets/application/blocks/practice/_category-practices-item.css +++ b/app/assets/stylesheets/application/blocks/practice/_category-practices-item.css @@ -1,4 +1,3 @@ - .category-practices-item { padding: .75rem 1rem; position: relative; @@ -36,9 +35,6 @@ flex: 0 0 2.75rem; } -.category-practices-item__anchor { -} - @media (width >= 48em) { .category-practices-item__anchor { position: relative; @@ -151,21 +147,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/_completion-massage.css b/app/assets/stylesheets/application/blocks/practice/_completion-massage.css deleted file mode 100644 index 7f11a9bb6a5..00000000000 --- a/app/assets/stylesheets/application/blocks/practice/_completion-massage.css +++ /dev/null @@ -1,3 +0,0 @@ -.completion-massage { - background-color: var(--success); -} diff --git a/app/assets/stylesheets/application/blocks/practice/_practice-content-actions.css b/app/assets/stylesheets/application/blocks/practice/_practice-content-actions.css deleted file mode 100644 index 2da477613a3..00000000000 --- a/app/assets/stylesheets/application/blocks/practice/_practice-content-actions.css +++ /dev/null @@ -1,45 +0,0 @@ - -.practice-content-actions__description { - font-size: .75rem; - line-height: 1.5; - margin-top: 0; - margin-bottom: .75rem; - text-align: center; -} - -.practice-content-actions__items { - display: flex; - align-items: flex-end; - justify-content: center; -} - -@media (width <= 47.9375em) { - .practice-content-actions__items { - flex-direction: column; - } -} - -.practice-content-actions__item { - padding-inline: .375rem; -} - -.practice-content-actions__item > * { - width: 10rem; - max-width: 100%; -} - -@media (width <= 47.9375em) { - .practice-content-actions__item { - width: 100%; - padding-inline: 0; - } - - .practice-content-actions__item:nth-child(2), - .practice-content-actions__item:nth-child(3) { - margin-top: .75rem; - } - - .practice-content-actions__item > * { - width: 100%; - } -} 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..335cc125eee 100644 --- a/app/assets/stylesheets/application/blocks/practice/_practice-status-buttons.css +++ b/app/assets/stylesheets/application/blocks/practice/_practice-status-buttons.css @@ -1,4 +1,3 @@ - .practice-status-buttons__start { text-align: center; width: 100%; @@ -6,9 +5,6 @@ align-items: center; } -.practice-status-buttons__label { -} - @media (width >= 48em) { .practice-status-buttons__label { font-size: .75rem; @@ -100,9 +96,6 @@ color: var(--muted-text); } -.practice-status__buttons-item { -} - @media (width <= 47.9375em) { .practice-status__buttons-item { width: 33.33%; @@ -120,3 +113,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/assets/stylesheets/application/blocks/static-pages/_thanks.css b/app/assets/stylesheets/application/blocks/static-pages/_thanks.css deleted file mode 100644 index 974f746272c..00000000000 --- a/app/assets/stylesheets/application/blocks/static-pages/_thanks.css +++ /dev/null @@ -1,70 +0,0 @@ -.thanks { - max-width: 26rem; - padding-inline: 1rem; - margin-inline: auto; - margin-top: 6rem; -} - -.thanks__logo-image { - max-width: 20rem; - display: block; - margin-inline: auto; - margin-bottom: 3rem; -} - -.thanks__body { - background-color: var(--base); - padding: 1.5rem; - border-radius: 0.25rem; -} - -.thanks__icon { - font-size: 4rem; - line-height: 1; - margin-top: 0; - margin-bottom: 0.5rem; - text-align: center; - color: var(--main); -} - -.thanks__title { - font-size: 0.875rem; - line-height: 1.4; - margin-top: 0; - margin-bottom: 0.75rem; - text-align: center; - font-weight: 600; -} - -.thanks__message p { - font-size: 0.75rem; - line-height: 1.6; - text-align: center; -} - -.thanks__message a { - transition: color 0.2s ease-in; - color: var(--link-text); - text-decoration: none; - cursor: pointer; -} - -.thanks__message a:link { - color: var(--link-text); -} - -.thanks__message a:visited { - color: hsl(233deg 33% 58%); -} - -.thanks__message a:hover { - color: var(--hover-text); -} - -.thanks__message a:active { - color: var(--hover-text); -} - -.thanks__message a:hover, .thanks__message a:active { - text-decoration: underline; -} diff --git a/app/assets/stylesheets/application/blocks/survey/survey-answer.css b/app/assets/stylesheets/application/blocks/survey/_survey-answer.css similarity index 100% rename from app/assets/stylesheets/application/blocks/survey/survey-answer.css rename to app/assets/stylesheets/application/blocks/survey/_survey-answer.css diff --git a/app/assets/stylesheets/application/blocks/survey/survey-answers.css b/app/assets/stylesheets/application/blocks/survey/_survey-answers.css similarity index 100% rename from app/assets/stylesheets/application/blocks/survey/survey-answers.css rename to app/assets/stylesheets/application/blocks/survey/_survey-answers.css diff --git a/app/assets/stylesheets/application/blocks/survey/survey-result.css b/app/assets/stylesheets/application/blocks/survey/_survey-result.css similarity index 98% rename from app/assets/stylesheets/application/blocks/survey/survey-result.css rename to app/assets/stylesheets/application/blocks/survey/_survey-result.css index 14709518890..4ddd54373f1 100644 --- a/app/assets/stylesheets/application/blocks/survey/survey-result.css +++ b/app/assets/stylesheets/application/blocks/survey/_survey-result.css @@ -186,5 +186,3 @@ margin-bottom: 1.5rem; } } - -/*# sourceMappingURL=survey-result.css.map */ diff --git a/app/assets/stylesheets/application/blocks/thread/_thread-comment-form.css b/app/assets/stylesheets/application/blocks/thread/_thread-comment-form.css index ea2db666dc6..5c345d15f15 100644 --- a/app/assets/stylesheets/application/blocks/thread/_thread-comment-form.css +++ b/app/assets/stylesheets/application/blocks/thread/_thread-comment-form.css @@ -1,6 +1,3 @@ -.thread-comment-form { -} - @media (width >= 48em) { .thread-comment-form { display: flex; @@ -38,9 +35,6 @@ border-radius: 50%; } -.thread-comment-form__form { -} - .thread-action-completed-form__form { flex: 1; position: relative; diff --git a/app/assets/stylesheets/application/blocks/thread/_thread-comment.css b/app/assets/stylesheets/application/blocks/thread/_thread-comment.css index 313c2f81a26..0e6b1f9ea66 100644 --- a/app/assets/stylesheets/application/blocks/thread/_thread-comment.css +++ b/app/assets/stylesheets/application/blocks/thread/_thread-comment.css @@ -1,4 +1,3 @@ - .thread-comment { position: relative; border-radius: .25rem; @@ -18,9 +17,6 @@ } } -.thread-comment__start { -} - @media (width >= 48em) { .thread-comment__start { flex: 0 0 var(--thread-header-author); @@ -35,9 +31,6 @@ } } -.thread-comment__end { -} - @media (width >= 48em) { .thread-comment__end { max-width: calc(100% - var(--thread-header-author)); @@ -64,9 +57,6 @@ } } -.thread-comment__title-user-icon { -} - @media (width >= 48em) { .thread-comment__title-user-icon { display: none; @@ -83,9 +73,6 @@ } } -.thread-comment__title-link { -} - @media (width >= 48em) { .thread-comment__title-link { height: 100%; diff --git a/app/assets/stylesheets/application/blocks/thread/_thread-comments-more.css b/app/assets/stylesheets/application/blocks/thread/_thread-comments-more.css index 3a7bac73525..2c67364430b 100644 --- a/app/assets/stylesheets/application/blocks/thread/_thread-comments-more.css +++ b/app/assets/stylesheets/application/blocks/thread/_thread-comments-more.css @@ -1,4 +1,3 @@ - .thread-comments-more { position: relative; } @@ -29,9 +28,6 @@ padding-block: 1.25rem; } -.thread-comments-more__action { -} - @media (width >= 48em) { .thread-comments-more__action { max-width: 16rem; diff --git a/app/assets/stylesheets/application/blocks/thread/_thread-form.css b/app/assets/stylesheets/application/blocks/thread/_thread-form.css deleted file mode 100644 index 79656d0ab6e..00000000000 --- a/app/assets/stylesheets/application/blocks/thread/_thread-form.css +++ /dev/null @@ -1,43 +0,0 @@ - -.thread-form { -} - -@media (width >= 48em) { - .thread-form { - padding: 1rem 2rem; - } -} - -@media (width <= 47.9375em) { - .thread-form { - padding: .75rem 1rem; - } -} - -.thread-form__actions { - margin-top: 1rem; -} - -@media (width >= 48em) { - .thread-form__actions { - display: flex; - justify-content: center; - gap: .75rem; - } -} - -.thread-form__action { -} - -@media (width >= 48em) { - .thread-form__action { - flex: 0 0 14rem; - max-width: 50%; - } -} - -@media (width <= 47.9375em) { - .thread-form__action:not(:first-child) { - margin-top: .5rem; - } -} diff --git a/app/assets/stylesheets/application/blocks/user/_active-practices-list.css b/app/assets/stylesheets/application/blocks/user/_active-practices-list.css deleted file mode 100644 index 70666491a5b..00000000000 --- a/app/assets/stylesheets/application/blocks/user/_active-practices-list.css +++ /dev/null @@ -1,41 +0,0 @@ -.active-practices-list__items { - margin-bottom: -0.25rem; -} - -.active-practices-list-item:not(:last-child) { - border-bottom: dotted 0.0625rem var(--border-tint); -} - -.active-practices-list-item__link { - font-size: 0.875rem; - line-height: 1.55; - display: block; - color: var(--main); - padding-block: 0.5rem; - padding-inline: 1rem; - text-decoration: none; - cursor: pointer; -} - -.active-practices-list-item__link:hover, .active-practices-list-item__link:active { - text-decoration: underline; -} - -.active-practices-list-item__link::before { - content: "\f060"; - font-family: "Font Awesome 7 Free"; - font-style: normal; - font-weight: 900; - font-variant: normal; - text-rendering: auto; - line-height: 1; - -moz-osx-font-smoothing: grayscale; - -webkit-font-smoothing: antialiased; - display: inline-block; - margin-right: 0.5rem; - color: var(--accent); -} - -.active-practices-list-item__link:hover::before { - text-decoration: none; -} diff --git a/app/assets/stylesheets/application/blocks/user/_companies-item.css b/app/assets/stylesheets/application/blocks/user/_companies-item.css index e4d4ee64961..f75cc078d40 100644 --- a/app/assets/stylesheets/application/blocks/user/_companies-item.css +++ b/app/assets/stylesheets/application/blocks/user/_companies-item.css @@ -1,11 +1,7 @@ - .companies-item { height: 100%; } -.companies-item__inner { -} - @media (width >= 48em) { .companies-item__inner { height: 100%; @@ -25,9 +21,6 @@ gap: 1rem; } -.companies-item__header-start { -} - @media (width >= 48em) { .companies-item__header-start { flex: 0 0 3.5rem; diff --git a/app/assets/stylesheets/application/blocks/user/_group-company-name.css b/app/assets/stylesheets/application/blocks/user/_group-company-name.css index 2bb5a14e68f..84edf904f53 100644 --- a/app/assets/stylesheets/application/blocks/user/_group-company-name.css +++ b/app/assets/stylesheets/application/blocks/user/_group-company-name.css @@ -1,7 +1,3 @@ - -.group-company-name { -} - @media (width >= 48em) { .group-company-name { font-size: 1.125rem; diff --git a/app/assets/stylesheets/application/blocks/user/_sns-links.css b/app/assets/stylesheets/application/blocks/user/_sns-links.css index 9bc8ed77e03..ce9ef21edd0 100644 --- a/app/assets/stylesheets/application/blocks/user/_sns-links.css +++ b/app/assets/stylesheets/application/blocks/user/_sns-links.css @@ -1,4 +1,3 @@ - .sns-links { margin-top: .5rem; display: flex; @@ -7,9 +6,6 @@ justify-content: space-between; } -.sns-links__items { -} - .sns-links__item-link { font-size: .875rem; } diff --git a/app/assets/stylesheets/application/blocks/user/_user-group.css b/app/assets/stylesheets/application/blocks/user/_user-group.css index d2040e6f633..da18853fc17 100644 --- a/app/assets/stylesheets/application/blocks/user/_user-group.css +++ b/app/assets/stylesheets/application/blocks/user/_user-group.css @@ -1,4 +1,3 @@ - .user-group { padding: 1rem; } @@ -24,9 +23,6 @@ } } -.user-group__title { -} - @media (width >= 48em) { .user-group__title { font-size: 1rem; @@ -96,9 +92,6 @@ color: var(--muted-text); } -.user-group__counts { -} - @media (width >= 48em) { .user-group__counts { margin-top: -.5rem; diff --git a/app/assets/stylesheets/application/blocks/user/_user-profile.css b/app/assets/stylesheets/application/blocks/user/_user-profile.css index 5494ad2ffd3..0830e07e4d1 100644 --- a/app/assets/stylesheets/application/blocks/user/_user-profile.css +++ b/app/assets/stylesheets/application/blocks/user/_user-profile.css @@ -1,4 +1,3 @@ - .user-profile__icon { @media (width <= 47.9375em) { position: static; @@ -20,14 +19,3 @@ } } - -.user-company-profile__description { - @media (width >= 48em) { - font-size: .9375rem; - } - - @media (width <= 47.9375em) { - font-size: .8125rem; - - } -} diff --git a/app/assets/stylesheets/application/blocks/user/_user-secret-attributes.css b/app/assets/stylesheets/application/blocks/user/_user-secret-attributes.css deleted file mode 100644 index 18df8d9a1f3..00000000000 --- a/app/assets/stylesheets/application/blocks/user/_user-secret-attributes.css +++ /dev/null @@ -1,46 +0,0 @@ -.user-secret-attributes { - font-size: 0.75rem; - border: solid 1px var(--border); - padding: 0.75em 1em; -} - -.user-secret-attributes:not(:first-child) { - margin-top: 0.25em; -} - -.user-secret-attributes__title { - font-size: 0.75rem; - line-height: 1.4; - font-weight: 600; - margin-bottom: 0.25em; -} - -.user-secret-attributes__items { - display: flex; - flex-wrap: wrap; - color: var(--muted-text); -} - -.user-secret-attributes__items + .user-secret-attributes__items { - margin-top: 0.125rem; -} - -.user-secret-attributes__item { - line-height: 1.4; - display: flex; - align-items: center; -} - -.user-secret-attributes__item:not(:first-child)::before { - content: "、"; -} - -.user-secret-attributes__item-label::after { - content: ":"; - margin-right: 0.25rem; -} - -.user-secret-attributes__item-value.is-important { - font-weight: 600; - color: var(--danger); -} diff --git a/app/assets/stylesheets/atoms/_a-border.css b/app/assets/stylesheets/atoms/_a-border.css index 729a8c9fb4a..fbe2de94d9a 100644 --- a/app/assets/stylesheets/atoms/_a-border.css +++ b/app/assets/stylesheets/atoms/_a-border.css @@ -18,10 +18,3 @@ display: block; border-top: solid 1px var(--danger); } - -.a-border-shade { - height: 1px; - width: 100%; - display: block; - border-top: solid 1px var(--border-shade); -} diff --git a/app/assets/stylesheets/atoms/_a-button.css b/app/assets/stylesheets/atoms/_a-button.css index e550061127b..080fd6410ef 100644 --- a/app/assets/stylesheets/atoms/_a-button.css +++ b/app/assets/stylesheets/atoms/_a-button.css @@ -149,9 +149,9 @@ input:checked + .a-button.is-checkbox::after { } .a-button.is-primary { - background-color: #5752e8; + background-color: var(--primary); color: white; - border-color: #5752e8; + border-color: var(--primary); } .a-button.is-primary:focus, .a-button.is-primary:active { @@ -160,7 +160,7 @@ input:checked + .a-button.is-checkbox::after { .a-button.is-primary:hover, .a-button.is-primary:focus, .a-button.is-primary:active { background-color: rgb(67.494552381 61.829877551 231.770122449); - border-color: #5752e8; + border-color: var(--primary); } .a-button.is-primary.is-disabled, .a-button.is-primary:disabled { @@ -170,9 +170,8 @@ input:checked + .a-button.is-checkbox::after { } .a-button.is-secondary { - background-color: hsl(242deg 1% 100%); - color: hsl(242deg 10% 30%); - border-color: hsl(242deg 3% 88%); + background-color: var(--secondary); + color: var(--default-text); border-color: var(--input-border); } @@ -192,9 +191,9 @@ input:checked + .a-button.is-checkbox::after { } .a-button.is-info { - background-color: hsl(190deg 73% 48%); + background-color: var(--info); color: white; - border-color: hsl(190deg 73% 48%); + border-color: var(--info); } .a-button.is-info:focus, .a-button.is-info:active { @@ -203,7 +202,7 @@ input:checked + .a-button.is-checkbox::after { .a-button.is-info:hover, .a-button.is-info:focus, .a-button.is-info:active { background-color: hsl(190deg 75% 44%); - border-color: hsl(190deg 73% 48%); + border-color: var(--info); } .a-button.is-info.is-disabled, .a-button.is-info:disabled { @@ -213,9 +212,9 @@ input:checked + .a-button.is-checkbox::after { } .a-button.is-success { - background-color: hsl(150deg 39% 49%); + background-color: var(--success); color: white; - border-color: hsl(150deg 39% 49%); + border-color: var(--success); } .a-button.is-success:focus, .a-button.is-success:active { @@ -224,7 +223,7 @@ input:checked + .a-button.is-checkbox::after { .a-button.is-success:hover, .a-button.is-success:focus, .a-button.is-success:active { background-color: hsl(150deg 41% 45%); - border-color: hsl(150deg 39% 49%); + border-color: var(--success); } .a-button.is-success.is-disabled, .a-button.is-success:disabled { @@ -234,9 +233,9 @@ input:checked + .a-button.is-checkbox::after { } .a-button.is-warning { - background-color: hsl(44deg 96% 54%); - color: hsl(242deg 10% 30%); - border-color: hsl(44deg 96% 54%); + background-color: var(--warning); + color: var(--default-text); + border-color: var(--warning); } .a-button.is-warning:focus, .a-button.is-warning:active { @@ -245,7 +244,7 @@ input:checked + .a-button.is-checkbox::after { .a-button.is-warning:hover, .a-button.is-warning:focus, .a-button.is-warning:active { background-color: hsl(44deg 98% 50%); - border-color: hsl(44deg 96% 54%); + border-color: var(--warning); } .a-button.is-warning.is-disabled, .a-button.is-warning:disabled { @@ -255,9 +254,9 @@ input:checked + .a-button.is-checkbox::after { } .a-button.is-danger { - background-color: hsl(349deg 90% 62%); + background-color: var(--danger); color: white; - border-color: hsl(349deg 90% 62%); + border-color: var(--danger); } .a-button.is-danger:focus, .a-button.is-danger:active { @@ -266,7 +265,7 @@ input:checked + .a-button.is-checkbox::after { .a-button.is-danger:hover, .a-button.is-danger:focus, .a-button.is-danger:active { background-color: hsl(349deg 92% 58%); - border-color: hsl(349deg 90% 62%); + border-color: var(--danger); } .a-button.is-danger.is-disabled, .a-button.is-danger:disabled { @@ -276,9 +275,9 @@ input:checked + .a-button.is-checkbox::after { } .a-button.is-disabled { - background-color: hsl(242deg 11% 85%); - color: hsl(242deg 10% 30%); - border-color: hsl(242deg 11% 85%); + background-color: var(--disabled); + color: var(--default-text); + border-color: var(--disabled); } .a-button.is-disabled:focus, .a-button.is-disabled:active { @@ -287,7 +286,7 @@ input:checked + .a-button.is-checkbox::after { .a-button.is-disabled:hover, .a-button.is-disabled:focus, .a-button.is-disabled:active { background-color: hsl(242deg 13% 81%); - border-color: hsl(242deg 11% 85%); + border-color: var(--disabled); } .a-button.is-disabled.is-disabled, .a-button.is-disabled:disabled { @@ -297,9 +296,9 @@ input:checked + .a-button.is-checkbox::after { } .a-button.is-main { - background-color: hsl(242deg 51% 51%); + background-color: var(--main); color: white; - border-color: hsl(242deg 51% 51%); + border-color: var(--main); } .a-button.is-main:focus, .a-button.is-main:active { @@ -308,7 +307,7 @@ input:checked + .a-button.is-checkbox::after { .a-button.is-main:hover, .a-button.is-main:focus, .a-button.is-main:active { background-color: hsl(242deg 53% 47%); - border-color: hsl(242deg 51% 51%); + border-color: var(--main); } .a-button.is-main.is-disabled, .a-button.is-main:disabled { @@ -318,9 +317,9 @@ input:checked + .a-button.is-checkbox::after { } .a-button.is-secondary.is-active { - background-color: #5752e8; + background-color: var(--primary); color: white; - border-color: #5752e8; + border-color: var(--primary); } .a-button.is-secondary.is-active:focus, .a-button.is-secondary.is-active:active { @@ -329,7 +328,7 @@ input:checked + .a-button.is-checkbox::after { .a-button.is-secondary.is-active:hover, .a-button.is-secondary.is-active:focus, .a-button.is-secondary.is-active:active { background-color: rgb(67.494552381 61.829877551 231.770122449); - border-color: #5752e8; + border-color: var(--primary); } .a-button.is-secondary.is-active.is-disabled, .a-button.is-secondary.is-active:disabled { @@ -346,8 +345,8 @@ input:checked + .a-button.is-checkbox::after { } .a-button.is-muted-bordered { - border-color: hsl(242deg 7% 89%); - color: hsl(242deg 5% 64%); + border-color: var(--border); + color: var(--muted-text); font-weight: 400 !important; background-color: white; } @@ -359,8 +358,8 @@ input:checked + .a-button.is-checkbox::after { .a-button.is-primary-border { background-color: rgb(255 255 255 / 10%); - color: #5752e8; - border-color: #5752e8; + color: var(--primary); + border-color: var(--primary); } .a-button.is-primary-border:focus, .a-button.is-primary-border:active { @@ -379,8 +378,8 @@ input:checked + .a-button.is-checkbox::after { .a-button.is-secondary-border { background-color: rgb(255 255 255 / 10%); - color: hsl(242deg 1% 100%); - border-color: hsl(242deg 1% 100%); + color: var(--secondary); + border-color: var(--secondary); } .a-button.is-secondary-border:focus, .a-button.is-secondary-border:active { @@ -407,7 +406,7 @@ input:checked + .a-button.is-checkbox::after { .a-button.is-text { text-decoration: underline; - color: #5752e8; + color: var(--primary); } .a-button.is-text:hover, @@ -439,7 +438,7 @@ input:checked + .a-button.is-checkbox::after { .a-button.is-text-reversal, .a-button.is-muted-text { - color: hsl(242deg 5% 64%); + color: var(--muted-text); } .a-button.is-xxs, diff --git a/app/assets/stylesheets/atoms/_a-card.css b/app/assets/stylesheets/atoms/_a-card.css index 91988e7d755..8e03a6c5514 100644 --- a/app/assets/stylesheets/atoms/_a-card.css +++ b/app/assets/stylesheets/atoms/_a-card.css @@ -22,11 +22,6 @@ margin-top: 1rem; } -.a-card + .a-card-react, -.a-card-react + .a-card { - margin-top: 1rem; -} - .a-card.is-borderd { border: solid 4px var(--default-text); } @@ -100,17 +95,13 @@ table.a-card { margin-bottom: 1rem; } - .a-card + .a-card, - .a-card + .a-card-react, - .a-card-react + .a-card { + .a-card + .a-card { margin-top: 1rem; } } @media (width >= 80em) { - .a-card + .a-card, - .a-card + .a-card-react, - .a-card-react + .a-card { + .a-card + .a-card { margin-top: 1.25rem; } } diff --git a/app/assets/stylesheets/atoms/_a-copy-button.css b/app/assets/stylesheets/atoms/_a-copy-button.css index c05c9921160..3039e1afb9f 100644 --- a/app/assets/stylesheets/atoms/_a-copy-button.css +++ b/app/assets/stylesheets/atoms/_a-copy-button.css @@ -11,7 +11,7 @@ } .a-copy-button.is-active { - background-color: hsl(242deg 51% 51%); + background-color: var(--main); color: var(--reversal-text); } diff --git a/app/assets/stylesheets/atoms/_a-hidden-scrollbar.css b/app/assets/stylesheets/atoms/_a-hidden-scrollbar.css deleted file mode 100644 index af2cae432ab..00000000000 --- a/app/assets/stylesheets/atoms/_a-hidden-scrollbar.css +++ /dev/null @@ -1,8 +0,0 @@ -.a-hidden-scrollbar { - -ms-overflow-style: none; - scrollbar-width: none; -} - -.a-hidden-scrollbar::-webkit-scrollbar { - display: none; -} diff --git a/app/assets/stylesheets/atoms/_a-horizontal-dashed.css b/app/assets/stylesheets/atoms/_a-horizontal-dashed.css deleted file mode 100644 index f4735cfd40c..00000000000 --- a/app/assets/stylesheets/atoms/_a-horizontal-dashed.css +++ /dev/null @@ -1,5 +0,0 @@ -.a-horizontal-dashed { - width: 100%; - height: 1px; - border-top: dashed 1px var(--border-shade); -} diff --git a/app/assets/stylesheets/atoms/_a-text-link.css b/app/assets/stylesheets/atoms/_a-text-link.css index d6711be9f68..be279191517 100644 --- a/app/assets/stylesheets/atoms/_a-text-link.css +++ b/app/assets/stylesheets/atoms/_a-text-link.css @@ -46,21 +46,11 @@ color: var(--hover-text); } -.a-hover-link-reversal, .a-reversal-text-link { text-decoration: none; cursor: pointer; -} - -.a-hover-link-reversal:hover, -.a-hover-link-reversal:active { - text-decoration: underline; -} - -.a-reversal-text-link { transition: color 0.2s ease-in; color: var(--reversal-text); - text-decoration: none; } .a-reversal-text-link:link { diff --git a/app/assets/stylesheets/atoms/_a-textarea-bottom-note.css b/app/assets/stylesheets/atoms/_a-textarea-bottom-note.css deleted file mode 100644 index aabf90685a6..00000000000 --- a/app/assets/stylesheets/atoms/_a-textarea-bottom-note.css +++ /dev/null @@ -1,30 +0,0 @@ -@media (width <= 63.9375em) { - .a-textarea-bottom-note__banner { - display: none; - } -} - -@media (width >= 64em) { - .a-textarea-bottom-note { - position: relative; - } - - .a-textarea-bottom-note .a-text-input { - padding-bottom: 3rem; - } - - .a-textarea-bottom-note__banner { - background-color: var(--background-semi-shade); - position: absolute; - left: 1px; - right: 1px; - bottom: 1px; - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; - padding: 0.5rem; - font-size: 0.75rem; - line-height: 1.4; - text-align: center; - border-top: solid 1px var(--border-shade); - } -} diff --git a/app/assets/stylesheets/common-imports.css b/app/assets/stylesheets/common-imports.css index 31267510cb0..7e911d9b850 100644 --- a/app/assets/stylesheets/common-imports.css +++ b/app/assets/stylesheets/common-imports.css @@ -39,7 +39,6 @@ @import "./atoms/_a-grab.css"; @import "./atoms/_a-grass.css"; @import "./atoms/_a-help.css"; -@import "./atoms/_a-horizontal-dashed.css"; @import "./atoms/_a-list-item-badge.css"; @import "./atoms/_a-long-text.css"; @import "./atoms/_a-markdown-input.css"; @@ -72,7 +71,6 @@ @import "./atoms/_a-tags.css"; @import "./atoms/_a-link-card.css"; @import "./atoms/_o-empty-message.css"; -@import "./atoms/_a-hidden-scrollbar.css"; @import "./shared/layouts/_wrapper.css"; @import "./shared/layouts/_container.css"; @import "./shared/layouts/_columns.css"; @@ -86,7 +84,6 @@ @import "./shared/blocks/_global-nav.css"; @import "./shared/blocks/_modal.css"; @import "./shared/blocks/_page-nav.css"; -@import "./shared/blocks/_page-search.css"; @import "./shared/blocks/_page-tags-nav.css"; @import "./shared/blocks/_pagination.css"; @import "./shared/blocks/_pill-nav.css"; @@ -107,7 +104,6 @@ @import "./shared/blocks/card/_tags-highlight.css"; @import "./shared/blocks/card-list/_card-list-item-actions.css"; @import "./shared/blocks/card-list/_card-list-item-meta.css"; -@import "./shared/blocks/card-list/_card-list-item-tags-edit.css"; @import "./shared/blocks/card-list/_card-list-item-title.css"; @import "./shared/blocks/card-list/_card-list-item.css"; @import "./shared/blocks/card-list/_card-list-tools.css"; @@ -132,7 +128,6 @@ @import "./shared/blocks/form/_hidden-form-item.css"; @import "./shared/blocks/form/_important-message.css"; @import "./shared/blocks/form/_linear-scale.css"; -@import "./shared/blocks/form/_many-check-boxes.css"; @import "./shared/blocks/form/_markdown-form.css"; @import "./shared/blocks/form/_radios.css"; @import "./shared/blocks/form/_vue-tags-input.css"; diff --git a/app/assets/stylesheets/config/variables/_css-variables.css b/app/assets/stylesheets/config/variables/_css-variables.css index cb7fde9394e..c60fe4a046b 100644 --- a/app/assets/stylesheets/config/variables/_css-variables.css +++ b/app/assets/stylesheets/config/variables/_css-variables.css @@ -23,15 +23,10 @@ --hover-text: hsl(215deg 78% 50%); --transparent-text: rgb(0 0 0 / 0%); --placeholder: rgb(0 0 0 / 7.5%); - --placeholder-text: hsl(242deg 0% 73%); --border: hsl(242deg 7% 89%); --border-tint: hsl(242deg 14% 95%); --border-shade: hsl(242deg 7% 84%); --border-more-shade: hsl(242deg 5% 64%); - --welcome-blue: hsl(210deg 100% 55%); - --welcome-light-blue: hsl(199deg 100% 92%); - --welcome-yellow: hsl(57deg 100% 49%); - --welcome-orange: hsl(40deg 98% 59%); --welcome-pink: hsl(344deg 80% 66%); --primary: #5752e8; --secondary: hsl(242deg 1% 100%); @@ -86,5 +81,3 @@ --thread-header-author: 4.75rem; --side-nav-width: 17rem; } - -/*# sourceMappingURL=_css-variables.css.map */ diff --git a/app/assets/stylesheets/form/_zip-tel-input.css b/app/assets/stylesheets/form/_zip-tel-input.css index e03cc205c15..f78cbbf2438 100644 --- a/app/assets/stylesheets/form/_zip-tel-input.css +++ b/app/assets/stylesheets/form/_zip-tel-input.css @@ -20,5 +20,3 @@ .a-name-input__field .a-form-label { margin-bottom: 0.25rem; } - -/*# sourceMappingURL=_zip-tel-input.css.map */ diff --git a/app/assets/stylesheets/lp.css b/app/assets/stylesheets/lp.css index 8ced09ebb34..7980d4b9a73 100644 --- a/app/assets/stylesheets/lp.css +++ b/app/assets/stylesheets/lp.css @@ -31,7 +31,6 @@ @import "./lp/blocks/lp/_lp-left-number-section.css"; @import "./lp/blocks/lp/_lp-capture-section.css"; @import "./lp/blocks/lp/_lp-top-image-sections.css"; -@import "./lp/blocks/lp/_lp-supplementary-info.css"; @import "./lp/blocks/lp/_lp-page-header.css"; @import "./lp/blocks/lp/_lp-page-header-nav.css"; @import "./lp/blocks/lp/_lp-price.css"; @@ -44,7 +43,6 @@ @import "./lp/blocks/lp/_lp-faqs.css"; @import "./lp/blocks/lp/_lp-faq.css"; @import "./lp/blocks/lp/_side-filter.css"; -@import "./lp/blocks/lp/_lp-course-selection.css"; @import "./lp/blocks/lp/_lp-course-selection-nav.css"; @import "./lp/blocks/lp/_lp-course-selection-item.css"; @import "./lp/blocks/lp/_lp-category-practices.css"; diff --git a/app/assets/stylesheets/lp/base/_base.css b/app/assets/stylesheets/lp/base/_base.css index 358493b3a0d..e938cc8c450 100644 --- a/app/assets/stylesheets/lp/base/_base.css +++ b/app/assets/stylesheets/lp/base/_base.css @@ -31,60 +31,3 @@ body.is-lp { color: var(--main); border-bottom: solid 1px var(--border); } - -.welcome-practices { - margin-top: 1.25rem; - display: flex; - flex-flow: column wrap; - gap: 0.75rem 0.5rem; -} - -.welcome-practices__item { - line-height: 1.6; - font-size: 0.8125rem; - padding-block: 0.5rem; -} - -.welcome-practices__item + .welcome-practices__item { - border-top: solid 1px var(--border-tint); -} - -.welcome-practices__item:first-child { - padding-top: 0; -} - -.welcome-practices__item:last-child { - padding-bottom: 0; -} - -@media (width >= 48em) { - .welcome-practices { - flex-direction: row; - } - - .welcome-practices__item { - font-size: 0.875rem; - padding-block: 0; - border-top: none !important; - } - - .welcome-practices__item:not(:last-child)::after { - content: "、"; - } -} - -.faqs-item__header { - padding-bottom: 0.5rem; - border-bottom: solid 1px var(--border); -} - -.faqs-item__body { - margin-top: 1.5rem; -} - -.faqs-item__title { - font-size: 1.25rem; - line-height: 1.6; - font-weight: 700; - color: var(--main); -} diff --git a/app/assets/stylesheets/lp/blocks/lp/_lp-content.css b/app/assets/stylesheets/lp/blocks/lp/_lp-content.css index 88a7c4092e9..8cb44c6c229 100644 --- a/app/assets/stylesheets/lp/blocks/lp/_lp-content.css +++ b/app/assets/stylesheets/lp/blocks/lp/_lp-content.css @@ -181,9 +181,3 @@ gap: 3.5rem; } } - -.lp-content__body { - display: flex; - flex-direction: column; - gap: 2.5rem; -} diff --git a/app/assets/stylesheets/lp/blocks/lp/_lp-course-selection-item.css b/app/assets/stylesheets/lp/blocks/lp/_lp-course-selection-item.css index b1d442169f0..ebac3eac07c 100644 --- a/app/assets/stylesheets/lp/blocks/lp/_lp-course-selection-item.css +++ b/app/assets/stylesheets/lp/blocks/lp/_lp-course-selection-item.css @@ -15,18 +15,6 @@ gap: 1.5rem; } -.lp-course-selection-item__title { - font-weight: 700; - font-size: 1.25rem; - line-height: 1.4; -} - -@media (width >= 48em) { - .lp-course-selection-item__title { - font-size: 1.5rem; - } -} - .lp-course-selection-item__action { max-width: 20rem; width: 100%; diff --git a/app/assets/stylesheets/lp/blocks/lp/_lp-course-selection.css b/app/assets/stylesheets/lp/blocks/lp/_lp-course-selection.css deleted file mode 100644 index c7ea80d176a..00000000000 --- a/app/assets/stylesheets/lp/blocks/lp/_lp-course-selection.css +++ /dev/null @@ -1,10 +0,0 @@ -.lp-course-selection__descritpion { - font-size: 0.875rem; - margin-bottom: 0.5rem; -} - -@media (width >= 48em) { - .lp-course-selection__descritpion { - font-size: 1rem; - } -} diff --git a/app/assets/stylesheets/lp/blocks/lp/_lp-header-nav.css b/app/assets/stylesheets/lp/blocks/lp/_lp-header-nav.css index c314b061864..d3666edae73 100644 --- a/app/assets/stylesheets/lp/blocks/lp/_lp-header-nav.css +++ b/app/assets/stylesheets/lp/blocks/lp/_lp-header-nav.css @@ -45,14 +45,12 @@ top: 0; bottom: 0; z-index: 2; - background-color: white; background-color: var(--base); width: 20rem; max-width: calc(100vw - 3rem); } .lp-header-nav__item { - border-bottom: solid 1px hsl(242deg 14% 95%); border-bottom: solid 1px var(--border-tint); } } @@ -60,7 +58,6 @@ .lp-header-nav__item-link { text-decoration: none; font-weight: 700; - color: hsl(242deg 51% 51%); color: var(--main); white-space: nowrap; display: flex; @@ -78,13 +75,11 @@ height: 2.25rem; padding-inline: 0.75rem; font-size: 0.875rem; - background-color: white; background-color: var(--base); border-radius: 0.5rem; } .lp-header-nav__item-link:hover { - background-color: #DEDEF2; background-color: var(--lp-bg-3); } } diff --git a/app/assets/stylesheets/lp/blocks/lp/_lp-price.css b/app/assets/stylesheets/lp/blocks/lp/_lp-price.css index d8be605f3b0..5b4865fd96d 100644 --- a/app/assets/stylesheets/lp/blocks/lp/_lp-price.css +++ b/app/assets/stylesheets/lp/blocks/lp/_lp-price.css @@ -98,15 +98,6 @@ gap: 0.125em; } -.lp-price__separator { - font-weight: 100; - align-self: baseline; -} - -.lp-price__per-person { - align-self: baseline; -} - .lp-price__tax { font-size: 1.25em; align-self: baseline; diff --git a/app/assets/stylesheets/lp/blocks/lp/_lp-supplementary-info.css b/app/assets/stylesheets/lp/blocks/lp/_lp-supplementary-info.css deleted file mode 100644 index cb4c1040d42..00000000000 --- a/app/assets/stylesheets/lp/blocks/lp/_lp-supplementary-info.css +++ /dev/null @@ -1,18 +0,0 @@ -.lp-supplementary-info { - background-color: var(--lp-bg-3); - border-radius: 0.5rem; -} - -.lp-supplementary-info__inner { - display: flex; - flex-direction: column; - padding: 0.75rem 1rem; - gap: 0.75rem; -} - -@media (width >= 48em) { - .lp-supplementary-info__inner { - padding: 1.5rem 2rem; - gap: 1.25rem; - } -} diff --git a/app/assets/stylesheets/lp/blocks/lp/_lp-top-cover.css b/app/assets/stylesheets/lp/blocks/lp/_lp-top-cover.css index dfd550fc003..b5e1fbd2c0f 100644 --- a/app/assets/stylesheets/lp/blocks/lp/_lp-top-cover.css +++ b/app/assets/stylesheets/lp/blocks/lp/_lp-top-cover.css @@ -44,42 +44,6 @@ margin-inline: auto; } -.lp-top-cover__image-container { - display: flex; - justify-content: center; - align-items: center; -} - -.lp-top-cover__logo { - height: auto; - width: 38rem; - display: block; - margin-top: 1.125rem; - margin-inline: auto; -} - -@media (width <= 47.9375em) { - .lp-top-cover__logo { - max-width: 90%; - margin-top: 0.75rem; - } -} - -.lp-top-cover__description { - font-size: 1.5rem; - line-height: 1.4; - text-align: center; - font-weight: 800; - color: var(--reversal-text); - font-feature-settings: "palt"; -} - -@media (width <= 47.9375em) { - .lp-top-cover__description { - font-size: 0.875rem; - } -} - .lp-top-cover__title { font-feature-settings: "palt"; background-color: var(--accent); diff --git a/app/assets/stylesheets/lp/blocks/lp/_lp-top-image-sections.css b/app/assets/stylesheets/lp/blocks/lp/_lp-top-image-sections.css index 2bf34cc76db..51ba6715dca 100644 --- a/app/assets/stylesheets/lp/blocks/lp/_lp-top-image-sections.css +++ b/app/assets/stylesheets/lp/blocks/lp/_lp-top-image-sections.css @@ -1,9 +1,3 @@ -.lp-top-image-sections__title-icon { - height: 3rem; - width: 3rem; - margin-right: 0.75rem; -} - .lp-top-image-section { height: 100%; } diff --git a/app/assets/stylesheets/lp/blocks/lp/_welcome-top-info.css b/app/assets/stylesheets/lp/blocks/lp/_welcome-top-info.css index b70af49f1ef..f9c2699af4f 100644 --- a/app/assets/stylesheets/lp/blocks/lp/_welcome-top-info.css +++ b/app/assets/stylesheets/lp/blocks/lp/_welcome-top-info.css @@ -14,30 +14,6 @@ } } -.welcome-top-info__label { - border: solid 1px var(--reversal-text); - background-color: var(--danger); - font-size: 0.75rem; - line-height: 1.4; - display: flex; - font-weight: 700; - white-space: nowrap; - color: var(--reversal-text); - padding: 0.25rem 0.75rem; - border-radius: 1rem; - align-items: center; - justify-content: center; - width: 10rem; - margin: -26px auto 0.5rem; -} - -@media (width >= 48em) { - .welcome-top-info__label { - margin: 0 0.5rem 0 0; - width: auto; - } -} - .welcome-top-info__link { color: var(--reversal-text); text-decoration: underline; diff --git a/app/assets/stylesheets/modules/_github.css b/app/assets/stylesheets/modules/_github.css index 3c5363b8efc..b0ad0a051d4 100644 --- a/app/assets/stylesheets/modules/_github.css +++ b/app/assets/stylesheets/modules/_github.css @@ -531,5 +531,3 @@ right: 2px; bottom: 0 !important; } - -/*# sourceMappingURL=_github.css.map */ diff --git a/app/assets/stylesheets/shared/blocks/_code-toolbar.css b/app/assets/stylesheets/shared/blocks/_code-toolbar.css index a1807ee2ac3..73bdfbe30dc 100644 --- a/app/assets/stylesheets/shared/blocks/_code-toolbar.css +++ b/app/assets/stylesheets/shared/blocks/_code-toolbar.css @@ -56,5 +56,3 @@ pre.code-toolbar > .toolbar span { background: #f8f8f8; border: 1px solid #ddd; } - -/*# sourceMappingURL=_code-toolbar.css.map */ diff --git a/app/assets/stylesheets/shared/blocks/_modal.css b/app/assets/stylesheets/shared/blocks/_modal.css index 0df34f99020..b56f7fd68f3 100644 --- a/app/assets/stylesheets/shared/blocks/_modal.css +++ b/app/assets/stylesheets/shared/blocks/_modal.css @@ -148,18 +148,3 @@ input:checked + .modal, font-size: 1rem; } } - -.modal-footer { -} - -@media (width >= 48em) { - .modal-footer { - padding: .25rem 1.5rem; - } -} - -@media (width <= 47.9375em) { - .modal-footer { - padding: .25rem 1rem; - } -} diff --git a/app/assets/stylesheets/shared/blocks/_page-nav.css b/app/assets/stylesheets/shared/blocks/_page-nav.css index cf9cad91837..9afde46f7cc 100644 --- a/app/assets/stylesheets/shared/blocks/_page-nav.css +++ b/app/assets/stylesheets/shared/blocks/_page-nav.css @@ -181,14 +181,6 @@ a.page-nav__title-inner:hover { font-weight: 600; } -.page-nav-metas { - margin-top: 0.25rem; - font-size: 0.75rem; - display: flex; - flex-wrap: wrap; - gap: 0.25rem 0.75rem; -} - .page-nav__item-link-inner { display: flex; text-decoration: none; diff --git a/app/assets/stylesheets/shared/blocks/_page-search.css b/app/assets/stylesheets/shared/blocks/_page-search.css deleted file mode 100644 index b93f32f1c37..00000000000 --- a/app/assets/stylesheets/shared/blocks/_page-search.css +++ /dev/null @@ -1,32 +0,0 @@ -.page-search { - padding-block: 0.75rem; - border-top: solid 1px var(--border-shade); - border-bottom: solid 1px var(--border-shade); -} - -.page-tabs + .page-search { - padding-block: 0; -} - -.page-search__form { - width: 30rem; - max-width: 100%; - margin-inline: auto; - display: flex; - height: 100%; - align-items: center; - padding-right: 0.75rem; -} - -.page-search__text-input { - height: 2rem; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - border-right: none; -} - -.is-icon.page-search__submit { - height: 2rem; - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} diff --git a/app/assets/stylesheets/shared/blocks/_prism-ghcolors.css b/app/assets/stylesheets/shared/blocks/_prism-ghcolors.css index c2c2ffc3ac6..b3713205e89 100644 --- a/app/assets/stylesheets/shared/blocks/_prism-ghcolors.css +++ b/app/assets/stylesheets/shared/blocks/_prism-ghcolors.css @@ -92,5 +92,3 @@ pre > code.highlight { outline: 0.4em solid red; outline-offset: 0.4em; } - -/*# sourceMappingURL=_prism-ghcolors.css.map */ diff --git a/app/assets/stylesheets/shared/blocks/card-list/_card-list-item-tags-edit.css b/app/assets/stylesheets/shared/blocks/card-list/_card-list-item-tags-edit.css deleted file mode 100644 index da5bf91c2ea..00000000000 --- a/app/assets/stylesheets/shared/blocks/card-list/_card-list-item-tags-edit.css +++ /dev/null @@ -1,19 +0,0 @@ -.card-list-item-tags-edit { - width: 100%; -} - -.card-list-item-tags__item-edit { - text-decoration: none; - cursor: pointer; - color: var(--main-text); -} - -.card-list-item-tags__item-edit:hover, .card-list-item-tags__item-edit:active { - text-decoration: underline; -} - -.card-list-item-tags-edit__actions { - margin-top: 0.5rem; - display: flex; - gap: 0.5rem; -} diff --git a/app/assets/stylesheets/shared/blocks/card-list/_card-list-item-title.css b/app/assets/stylesheets/shared/blocks/card-list/_card-list-item-title.css index a1b5bdcad99..c47d0f6f805 100644 --- a/app/assets/stylesheets/shared/blocks/card-list/_card-list-item-title.css +++ b/app/assets/stylesheets/shared/blocks/card-list/_card-list-item-title.css @@ -1,4 +1,3 @@ - .card-list-item-title { display: flex; justify-content: space-between; @@ -30,9 +29,6 @@ } } -.card-list-item-title__start { -} - @media (width >= 48em) { .card-list-item-title__start { display: flex; diff --git a/app/assets/stylesheets/shared/blocks/card-list/_card-list-item.css b/app/assets/stylesheets/shared/blocks/card-list/_card-list-item.css index 420edb90141..b52b978eb9f 100644 --- a/app/assets/stylesheets/shared/blocks/card-list/_card-list-item.css +++ b/app/assets/stylesheets/shared/blocks/card-list/_card-list-item.css @@ -1,4 +1,3 @@ - .card-list-item { position: relative; } @@ -143,9 +142,6 @@ a.card-list-item__inner:hover .card-list-item-title__title { margin: -1rem; } -.card-list-item__user { -} - @media (width >= 48em) { .card-list-item__user { min-width: 2.75rem; @@ -158,9 +154,6 @@ a.card-list-item__inner:hover .card-list-item-title__title { } } -.card-list-item__user-icon { -} - @media (width >= 48em) { .card-list-item__user-icon { height: 2.75rem; @@ -240,9 +233,6 @@ input:checked + .card-list-item__user-detail { display: block; } -.card-list-item__user-description { -} - @media (width >= 48em) { .card-list-item__user-description { font-size: .8125rem; @@ -320,9 +310,6 @@ input:checked + .card-list-item__user-detail { border-radius: 4px; } -.card-list-item__checker { -} - @media (width >= 48em) { .card-list-item__checker { position: absolute; diff --git a/app/assets/stylesheets/shared/blocks/card/_card-books.css b/app/assets/stylesheets/shared/blocks/card/_card-books.css index 53db5550985..01bdcf41c3a 100644 --- a/app/assets/stylesheets/shared/blocks/card/_card-books.css +++ b/app/assets/stylesheets/shared/blocks/card/_card-books.css @@ -1,16 +1,9 @@ - -.card-books-item { -} - @media (width >= 48em) { .card-books-item { height: 100%; } } -.card-books-item__body { -} - @media (width >= 48em) { .card-books-item__body { flex: 1; @@ -38,9 +31,6 @@ } } -.card-books-item__start { -} - @media (width >= 48em) { .card-books-item__start { flex: 0 0 4rem; diff --git a/app/assets/stylesheets/shared/blocks/form/_form-actions.css b/app/assets/stylesheets/shared/blocks/form/_form-actions.css index 14390c2bb8c..167609679a1 100644 --- a/app/assets/stylesheets/shared/blocks/form/_form-actions.css +++ b/app/assets/stylesheets/shared/blocks/form/_form-actions.css @@ -1,7 +1,3 @@ - -.form-actions { -} - @media (width >= 48em) { .form-actions { margin-top: 1.5rem; diff --git a/app/assets/stylesheets/shared/blocks/form/_form-added-choice.css b/app/assets/stylesheets/shared/blocks/form/_form-added-choice.css index 6bfadfce442..44f01faca81 100644 --- a/app/assets/stylesheets/shared/blocks/form/_form-added-choice.css +++ b/app/assets/stylesheets/shared/blocks/form/_form-added-choice.css @@ -1,14 +1,7 @@ - -.form-added-choice { -} - .form-added-choice + .form-added-choice { margin-top: 1rem; } -.form-added-choice__inner { -} - @media (width >= 48em) { .form-added-choice__inner { display: flex; @@ -31,9 +24,6 @@ } } -.form-added-choice__action { -} - @media (width <= 47.9375em) { .form-added-choice__action { margin-top: .75rem; diff --git a/app/assets/stylesheets/shared/blocks/form/_form-item-actions.css b/app/assets/stylesheets/shared/blocks/form/_form-item-actions.css index de6f2a61408..ff9fb0cd1df 100644 --- a/app/assets/stylesheets/shared/blocks/form/_form-item-actions.css +++ b/app/assets/stylesheets/shared/blocks/form/_form-item-actions.css @@ -1,7 +1,3 @@ - -.form-item-actions { -} - @media (width >= 48em) { .form-item-actions { margin-top: -.75rem; diff --git a/app/assets/stylesheets/shared/blocks/form/_form-item.css b/app/assets/stylesheets/shared/blocks/form/_form-item.css index 0412b936413..9123ca209fc 100644 --- a/app/assets/stylesheets/shared/blocks/form/_form-item.css +++ b/app/assets/stylesheets/shared/blocks/form/_form-item.css @@ -1,6 +1,3 @@ -.form-item { -} - .form-item:not(:last-child) { margin-bottom: 1.75rem; } @@ -197,9 +194,6 @@ font-weight: 400; } -.form-item__add-times { -} - @media (width >= 48em) { .form-item__add-times { margin-top: -.125rem; diff --git a/app/assets/stylesheets/shared/blocks/form/_form-notice.css b/app/assets/stylesheets/shared/blocks/form/_form-notice.css index 3c5af9caffc..2c0fff92335 100644 --- a/app/assets/stylesheets/shared/blocks/form/_form-notice.css +++ b/app/assets/stylesheets/shared/blocks/form/_form-notice.css @@ -1,7 +1,3 @@ - -.form-notice { -} - .form-notice:not(:first-child) { margin-top: 1em; } diff --git a/app/assets/stylesheets/shared/blocks/form/_form-times.css b/app/assets/stylesheets/shared/blocks/form/_form-times.css index 182aee1ea88..1869211d84b 100644 --- a/app/assets/stylesheets/shared/blocks/form/_form-times.css +++ b/app/assets/stylesheets/shared/blocks/form/_form-times.css @@ -1,7 +1,3 @@ - -.form-times { -} - @media (width >= 48em) { .form-times { display: flex; @@ -36,9 +32,6 @@ } } -.form-times__action { -} - @media (width <= 47.9375em) { .form-times__action { margin-top: .75rem; diff --git a/app/assets/stylesheets/shared/blocks/form/_many-check-boxes.css b/app/assets/stylesheets/shared/blocks/form/_many-check-boxes.css deleted file mode 100644 index 821c2f8ddde..00000000000 --- a/app/assets/stylesheets/shared/blocks/form/_many-check-boxes.css +++ /dev/null @@ -1,29 +0,0 @@ -.many-check-boxes { - max-height: 16rem; - overflow-y: auto; - background-color: #f7f7f7; - border: solid 1px #c1c5b9; - border-radius: 0.25rem; -} - -.many-check-boxes__item-label { - font-size: 0.8125rem; - line-height: 1.4; - display: block; - padding: 0.5rem; - border-bottom: solid 1px #ccc; - cursor: pointer; -} - -.many-check-boxes__item-label.is-checked { - background-color: var(--accent); - color: var(--main-text); -} - -.many-check-boxes__item:last-child .many-check-boxes__item-label { - border-bottom: none; -} - -.many-check-boxes__item-label input { - margin-right: 0.75rem; -} diff --git a/app/assets/stylesheets/shared/layouts/_columns.css b/app/assets/stylesheets/shared/layouts/_columns.css index 56291870891..b166af0b8dd 100644 --- a/app/assets/stylesheets/shared/layouts/_columns.css +++ b/app/assets/stylesheets/shared/layouts/_columns.css @@ -25,7 +25,6 @@ margin-top: 1.5rem; } - /* ========================================================= Gutter variations ========================================================= */ 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_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_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/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/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..412d5ed5ac7 100644 --- a/app/models/practice.rb +++ b/app/models/practice.rb @@ -211,6 +211,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/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/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/show.html.slim b/app/views/practices/show.html.slim index ac034000e37..23f0443d77c 100644 --- a/app/views/practices/show.html.slim +++ b/app/views/practices/show.html.slim @@ -124,7 +124,7 @@ hr.a-border-tint footer.card-footer = render(Learnings::LearningComponent.new(practice: @practice, current_user:)) - - if @practice.submission + - 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 +135,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/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/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..bfafc8bef63 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) @@ -26,22 +22,22 @@ report7_check_machida: user: machida checkable: report7 (Report) -procuct2_check_komagata: +product2_check_komagata: user: komagata checkable: product2 (Product) -procuct3_check_komagata: +product3_check_komagata: user: komagata checkable: product3 (Product) -procuct4_check_komagata: +product4_check_komagata: user: komagata checkable: product4 (Product) -procuct7_check_komagata: +product7_check_komagata: user: komagata checkable: product7 (Product) -procuct9_check_komagata: +product9_check_komagata: user: komagata checkable: product9 (Product) 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..99e52cbd229 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 @@ -595,6 +595,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 diff --git a/db/seeds.rb b/db/seeds.rb index 53efc3725c6..da6e59505fe 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -79,3 +79,4 @@ ] ActiveRecord::FixtureSet.create_fixtures 'db/fixtures', tables +Bootcamp::Setup.attachment if Rails.env.development? 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..2319c2f016b 100644 --- a/lib/tasks/bootcamp.rake +++ b/lib/tasks/bootcamp.rake @@ -57,6 +57,7 @@ namespace :bootcamp do puts '== START Cloud Build Task ==' Rake::Task['smart_search:generate_all'].invoke + BulkGenerateMovieThumbnailJob.perform_now puts '== END Cloud Build Task ==' end 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/application_visual_test_case.rb b/test/application_visual_test_case.rb new file mode 100644 index 00000000000..6f88cbd15e2 --- /dev/null +++ b/test/application_visual_test_case.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'application_system_test_case' +require 'capybara_screenshot_diff/minitest' + +# Base class for visual regression tests. +# +# These tests capture a screenshot of a page and compare it, pixel by pixel, +# against a committed baseline image. They are meant to catch *unintended* +# visual changes (e.g. during a CSS refactor). +# +# IMPORTANT: baseline images are environment-sensitive. Font anti-aliasing +# differs between macOS and Linux, so a baseline generated on a Mac will not +# match one compared on CI. Always generate and compare baselines in the SAME +# environment (the CI Ubuntu image or an equivalent Docker container). +# See test/visual/README.md for the full workflow. +class ApplicationVisualTestCase < ApplicationSystemTestCase + include CapybaraScreenshotDiff::Minitest::Assertions + + # Screenshots (baseline + diff) live under test/visual/screenshots. + Capybara::Screenshot.save_path = 'test/visual/screenshots' + # Compare against the whole page at a fixed viewport for reproducibility. + Capybara::Screenshot.window_size = [1400, 1400] + # Allow a tiny tolerance so sub-pixel anti-aliasing noise does not fail a run. + # Passed per-screenshot below (module-level key varies between gem versions). + TOLERANCE = 0.001 + # Wait until the rendered image stops changing before snapping (async UI). + Capybara::Screenshot.stability_time_limit = 0.5 + # Do NOT use the gem's blur_active_element: it calls `.click` on the value + # returned by evaluate_script(document.activeElement), which the + # capybara-playwright-driver serializes to a Hash (not a Capybara node), + # raising NoMethodError. The blinking caret is instead neutralized by the + # `caret-color: transparent` rule injected via STABILIZE_CSS below. + Capybara::Screenshot.blur_active_element = false + + # CSS injected before every capture to remove non-deterministic motion. + STABILIZE_CSS = <<~CSS + *, *::before, *::after { + transition: none !important; + animation: none !important; + caret-color: transparent !important; + scroll-behavior: auto !important; + } + CSS + + # Elements whose content changes run to run (heatmaps, avatars pulled from + # Gravatar, relative timestamps, …). They are masked so only layout/styling + # is compared. Tune this list for your pages. + DEFAULT_MASK_SELECTORS = [ + '.a-user-icons', + '.a-user-icon', # the avatar itself carries this class (header "Me", comments) + '.a-user-icon img', + '.user-icon img', + '.a-grass', + '.user-grass', + '.niconico-calendar', + '.a-elapsed-days', + '.random-tags-items', # users index sidebar renders a RANDOM set of tags + 'time' + ].freeze + + setup do + # Local-only tool. Baselines are environment-specific and intentionally not + # committed, so on CI there is nothing to compare against and the gem's + # fail_if_new (true when ENV["CI"] is set) would fail every test. CI already + # excludes test/visual from its test glob; this skip is the safety net. + # See test/visual/README.md. + skip 'Visual regression tests are local-only (not run on CI)' if ENV['CI'].present? + # Freeze time so relative timestamps ("3日前") and elapsed-day badges are + # stable across runs. + travel_to Time.zone.local(2025, 1, 1, 12, 0, 0) + end + + teardown do + travel_back + end + + # Capture the current page and compare it against the baseline named +name+. + # Waits for JS components, fonts, and lazy images before snapping. + # + # +extra_mask+ adds page-specific selectors on top of DEFAULT_MASK_SELECTORS + # (e.g. a panel whose row order/height is non-deterministic on that page). + def capture(name, mask: DEFAULT_MASK_SELECTORS, extra_mask: []) + wait_for_javascript_components + wait_for_fonts_and_images + stabilize_page(mask + extra_mask) + screenshot(name, tolerance: TOLERANCE) + end + + private + + def stabilize_page(mask_selectors) + css = STABILIZE_CSS.dup + css << mask_selectors.map { |sel| "#{sel} { visibility: hidden !important; }" }.join("\n") + execute_script(<<~JS, css) + const style = document.createElement('style'); + style.setAttribute('data-visual-test', 'stabilize'); + style.textContent = arguments[0]; + document.head.appendChild(style); + JS + end + + def wait_for_fonts_and_images + page.document.synchronize(5) do + ready = evaluate_script(<<~JS) + (document.fonts ? document.fonts.status === 'loaded' : true) && + Array.from(document.images).every((img) => img.complete) + JS + raise Capybara::ElementNotFound, 'fonts/images not ready' unless ready + end + rescue Capybara::ElementNotFound + # Best effort: proceed even if a slow asset never settles. + nil + end +end diff --git a/test/deliveries/activity_delivery_test.rb b/test/deliveries/activity_delivery_test.rb index 109f42c1261..9e738e95097 100644 --- a/test/deliveries/activity_delivery_test.rb +++ b/test/deliveries/activity_delivery_test.rb @@ -40,7 +40,7 @@ class ActivityDeliveryTest < ActiveSupport::TestCase end test '.notify(:checked)' do - check = checks(:procuct2_check_komagata) + check = checks(:product2_check_komagata) params = { check:, receiver: check.receiver diff --git a/test/fixtures/checks.yml b/test/fixtures/checks.yml index 3d6b393d6ee..ddce08ace66 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) @@ -26,30 +22,30 @@ report7_check_machida: user: machida checkable: report7 (Report) -procuct2_check_komagata: +product2_check_komagata: user: komagata checkable: product2 (Product) -procuct3_check_komagata: +product3_check_komagata: user: komagata checkable: product3 (Product) -procuct4_check_komagata: +product4_check_komagata: user: komagata checkable: product4 (Product) -procuct7_check_komagata: +product7_check_komagata: user: komagata checkable: product7 (Product) -procuct9_check_komagata: +product9_check_komagata: user: komagata checkable: product9 (Product) -procuct67_check_long-id-mentor: +product67_check_long-id-mentor: user: long-id-mentor checkable: product67 (Product) -procuct75_check_komagata: +product75_check_komagata: user: komagata checkable: product75 (Product) diff --git a/test/fixtures/comments.yml b/test/fixtures/comments.yml index 018902f2423..b70af3abd47 100644 --- a/test/fixtures/comments.yml +++ b/test/fixtures/comments.yml @@ -5,6 +5,10 @@ comment1: commentable: report1 (Report) description: |- CSSは奥が深いですね。 + # Explicit timestamp so report1's two comments (this + comment11 at now+2s) + # keep a deterministic order; visual regression tests need a stable layout. + created_at: <%= now %> + updated_at: <%= now %> comment2: user: komagata 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/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/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/mailers/activity_mailer_test.rb b/test/mailers/activity_mailer_test.rb index d09dcd679fd..6c9a67ff3a0 100644 --- a/test/mailers/activity_mailer_test.rb +++ b/test/mailers/activity_mailer_test.rb @@ -403,7 +403,7 @@ class ActivityMailerTest < ActionMailer::TestCase end test 'checked' do - check = checks(:procuct2_check_komagata) + check = checks(:product2_check_komagata) ActivityMailer.checked( sender: check.sender, @@ -420,7 +420,7 @@ class ActivityMailerTest < ActionMailer::TestCase end test 'trainee and advisers receive different wording in their acceptance emails' do - check = checks(:procuct75_check_komagata) + check = checks(:product75_check_komagata) ActivityMailer.checked( sender: check.sender, @@ -455,7 +455,7 @@ class ActivityMailerTest < ActionMailer::TestCase end test 'checked with params' do - check = checks(:procuct2_check_komagata) + check = checks(:product2_check_komagata) mailer = ActivityMailer.with( sender: check.sender, @@ -476,7 +476,7 @@ class ActivityMailerTest < ActionMailer::TestCase end test 'checked with user who have been denied' do - check = checks(:procuct2_check_komagata) + check = checks(:product2_check_komagata) ActivityMailer.checked( sender: check.sender, receiver: users(:hajime), diff --git a/test/mailers/previews/activity_mailer_preview.rb b/test/mailers/previews/activity_mailer_preview.rb index 2eecdb1034f..d60f490fc39 100644 --- a/test/mailers/previews/activity_mailer_preview.rb +++ b/test/mailers/previews/activity_mailer_preview.rb @@ -21,7 +21,7 @@ def graduated end def checked - check = Check.find(ActiveRecord::FixtureSet.identify(:procuct2_check_komagata)) + check = Check.find(ActiveRecord::FixtureSet.identify(:product2_check_komagata)) ActivityMailer.with(receiver: check.receiver, check:).checked 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/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 diff --git a/test/visual/README.md b/test/visual/README.md new file mode 100644 index 00000000000..70b9d9d59f6 --- /dev/null +++ b/test/visual/README.md @@ -0,0 +1,186 @@ +# ビジュアルリグレッションテスト + +ページのスクリーンショットを撮り、ピクセル単位で比較して **意図しない見た目の +変化** を検出します。CSS を触るときに「レイアウトがズレていないか」を手元で +確かめるための、**ローカル専用**のツールです(CI には組み込みません)。 + +- 基盤: 既存の Minitest system test(Capybara + `capybara-playwright-driver`) +- 比較: [`capybara-screenshot-diff`](https://github.com/donv/capybara-screenshot-diff)(1.12 系。`require: false` で導入し、ベースクラスで `capybara_screenshot_diff/minitest` を require) +- ベースクラス: `test/application_visual_test_case.rb` +- テスト: `test/visual/*_test.rb` +- 画像: `test/visual/screenshots/`(`.gitignore` 済み。**画像はコミットしない**) +- 画像比較には ImageMagick を使う: `brew install imagemagick` + +## ⚠️ 最重要: before / after は同じマシンで撮る + +スクリーンショットはフォントのアンチエイリアスなどが **OS・マシンによって微妙に +異なります**。別環境で撮った画像同士を比べると、CSS を一切変えていなくても全面 +差分になります。 + +**「変更前」と「変更後」は必ず同じマシン・同じブラウザで撮って比較してください。** +撮った画像は環境依存なので **リポジトリにコミットしないこと**(`test/visual/ +screenshots/` は `.gitignore` 済み)。 + +## セットアップ + +```sh +bundle install # capybara-screenshot-diff を導入(Gemfile.lock 更新済み) +npx playwright install chromium +``` + +## ローカルで画面のズレをチェックする(普段使い) + +CSS を編集する前後でスクショを撮り、ピクセル比較して差分を見ます。**画像を +コミットする必要はありません**。テストは撮った画像を `test/visual/screenshots/` +に書き出すので、それを「変更前」「変更後」で退避して比べるだけです。 + +```sh +# 0) (初回だけ)ブラウザと ImageMagick +npx playwright install chromium +brew install imagemagick + +# 1) 変更前の状態でスナップショットを撮って退避 +bin/rails test test/visual/pages_visual_test.rb +mkdir -p tmp/visual/before && cp test/visual/screenshots/*.png tmp/visual/before/ + +# 2) ここで CSS を編集する +# app/assets/stylesheets/*.css を編集したら Tailwind を再ビルド: +bin/rails tailwindcss:build + +# 3) 変更後をもう一度撮る +rm -f test/visual/screenshots/*.png +bin/rails test test/visual/pages_visual_test.rb +mkdir -p tmp/visual/after && cp test/visual/screenshots/*.png tmp/visual/after/ + +# 4) 比較。差分ピクセル数を表示し、ズレた箇所は *.diff.png で可視化 +for f in tmp/visual/before/*.png; do + n=$(basename "$f") + d=$(compare -metric AE "$f" "tmp/visual/after/$n" "tmp/visual/${n%.png}.diff.png" 2>&1) + printf '%-16s %s px\n' "${n%.png}" "$d" +done +``` + +読み方: + +- **0 または数十 px** … 実質一致(サブピクセルのアンチエイリアス揺れ)。ズレなし。 +- **数千 px 以上** … どこかがズレている。`tmp/visual/.diff.png` を開くと + 変わったピクセルが赤くハイライトされるので、そこを確認する。 + +差分が「自分が意図した変更」なら OK。意図していない箇所が赤くなっていたら +レイアウト崩れなので直す。`tmp/` は `.gitignore` 済みなので後片付けも不要。 + +> 補足(最初の1ページが遅い / タイムアウトするとき): sprockets が CSS を初回 +> コンパイルするため、キャッシュが冷えていると最初のページ遷移が数十秒かかり +> ます。落ちたらもう一度実行すれば、2 回目以降は warm で速くなります。 + +### 対象ページを増やす + +`test/visual/pages_visual_test.rb` にテストを足すだけです。動的要素があれば +`capture('name', extra_mask: ['.selector'])` でマスクを追加します(次節参照)。 + +### (参考)gem 標準の pass/fail フロー + +`bin/rails test` の合否だけで判定したい場合、gem は working tree のスクショを +**git の HEAD にコミット済みのベースライン**(`git show HEAD:` で取得)と +比較します。つまりベースラインを一度コミットしておけば、以降は `bin/rails test` +の赤/緑でズレを検出できます。 + +> ⚠️ **未コミットのベースラインは比較されません(false green の罠)。** ローカルの +> `fail_if_new` は false なので、ベースラインが HEAD に無いと「新規=pass」になり、 +> 差分があっても緑になります。この方式を使うなら画像を commit すること(ただし +> 環境依存なので push はしない・ローカル限定運用)。普段使いは上の before/after +> 方式のほうが手軽で安全です。 + +## このリファクタの検証(before / after)— ✅ 実施済み + +`css_refactor` が本当に見た目を変えていないかを、`main` の CSS と +`css_refactor` の CSS を **同一環境(同じ Mac・同じブラウザ・時刻固定)** で +描画して 6 ページをピクセル比較して裏取りしました。 + +やり方(上の「普段使い」と同じく、画像をコミットせず直接 before/after 比較): + +```sh +# 1. main のコンパイル済み CSS を app/assets/builds/ に入れて sprockets を warm、 +# 6ページ生成 → before セットとして退避 +# 2. css_refactor の CSS に差し替えて warm、6ページ生成 → after セット +# 3. before/after を ImageMagick で AE 比較(compare -metric AE a.png b.png) +``` + +結果(before=main CSS / after=css_refactor CSS、決定化の対応後): + +| ページ | 差分px | 判定 | +|---|---|---| +| dashboard / report_form / users_index / lp_practices | 0 | 完全一致 | +| report_show | 129 (0.007%) | サブピクセルの AA ノイズ | +| user_profile | 46 (0.002%) | サブピクセルの AA ノイズ | + +→ **リファクタによる見た目の変化なし**を実描画で確認済み。残差はすべて許容値 +(`TOLERANCE = 0.001` = 0.1%)を大きく下回るアンチエイリアスのゆらぎ。 + +同一 CSS で 2 回撮って比較する「再現性テスト」でも全ページ差分 0〜100px 未満で、 +決定的に描画されることを確認しています。 + +> 補足: gem 標準の「main でベースライン生成 → ブランチで比較」フローでもよいが、 +> その場合はベースラインを **commit してから** 比較すること(上の false-green の罠)。 + +## 動的コンテンツの扱い + +`application_visual_test_case.rb` で以下を自動化しています。 + +- `travel_to` による時刻固定(相対時間・経過日数バッジの安定化) +- transition / animation / キャレットの無効化 CSS 注入 +- アバター・草(グラフ)・時刻など非決定要素のマスク(`DEFAULT_MASK_SELECTORS`) +- フォント・画像のロード待ち、描画の安定待ち(`stability_time_limit`) + +ページ固有の動的要素は、各テストで `capture('name', extra_mask: [...])` に +セレクタを渡すとデフォルトマスクに追加できます。 + +### 実際に踏んだ非決定要素(対応済み) + +検証中に、CSS とは無関係に「実行ごとに描画が変わる」箇所がいくつか見つかったので +対応済み。同種の揺れに当たったら参考に。 + +- **コメントの表示順**(report show): `report1` の 2 件のコメントが `created_at` + 同値でタイになり順序が非決定 → `test/fixtures/comments.yml` の `comment1` に + 明示 `created_at` を付与して決定化。 +- **アバター画像**: 実体は `img.a-user-icon`(img 自身がクラス保持)。旧セレクタ + `.a-user-icon img` では拾えないため `.a-user-icon` を DEFAULT_MASK に追加。 +- **ユーザータグのサイドバー**(users index): `_random_tags` が文字どおり + ランダム抽出 → `.random-tags-items` を DEFAULT_MASK に追加。 +- **「直近の日報」サイドパネル**(report show): `@recent_reports` の並び/末尾 + カードの高さが非決定 → report show テストで `#side-tabs-content-1` を extra_mask。 + +## 進捗と残 TODO + +ローカル(macOS)で実行系まで通し、リファクタの見た目不変を裏取り済み。 + +1. [x] `bundle install`(`Gemfile.lock` に `capybara-screenshot-diff` 反映済み)。 +2. [x] `npx playwright install chromium`。 +3. [x] `bin/rails test test/visual/pages_visual_test.rb` が 6 ページ green で実行可能。 +4. [x] マスク・並び順の微調整(上「実際に踏んだ非決定要素」参照)。同一 CSS 2 回撮りで + 全ページ再現性を確認。 +5. [x] before/after で `main` ↔ `css_refactor` を実描画比較し、**見た目不変を裏取り** + (上「このリファクタの検証」参照)。 +6. [—] CI 組み込みは **見送り**。スクショが環境依存で不安定になりやすいため、 + ローカルで手元確認する用途に割り切る。 +7. [ ] 対象ページを代表画面から徐々に拡張。 + +補足: +- CSS リファクタ本体(デッドコード削除・色の変数化など)は `css_refactor` に別途 + コミット済みで、コンパイル後 CSS の値不変も検証済み。本テストは実描画での裏取り。 +- ローカル検証は同一 Mac 環境での **CSS 差し替え比較**で行った(画像はリポジトリに + コミットしていない = 環境依存の画像を履歴に残していない)。 + +## CI には組み込まない + +ビジュアル差分は OS・マシンでスクショが微妙に変わり CI では不安定になりやすいので、 +**あえて CI には載せず、ローカルで手元確認するツール**として運用します。上の +「ローカルで画面のズレをチェックする」手順を、CSS を触ったときに回してください。 + +CI から外すために 2 段で守っています: + +- `.circleci/config.yml` のテスト glob で `test/visual/` を除外 + (`... | grep -v '^test/visual/' | ...`)。CI はこれらを実行しない。 +- 保険として、`ApplicationVisualTestCase` の setup で `ENV['CI']` がある場合は + `skip` する。CI 環境では gem の `fail_if_new` が true になり、コミット済み + ベースラインが無いと全テストが失敗するため(この失敗を避ける)。 diff --git a/test/visual/pages_visual_test.rb b/test/visual/pages_visual_test.rb new file mode 100644 index 00000000000..0d9044b19d9 --- /dev/null +++ b/test/visual/pages_visual_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'application_visual_test_case' + +# Visual regression coverage for a curated set of representative screens. +# +# Start thin: these pages exercise the shared building blocks that the CSS +# refactor touched (buttons, cards, forms, page/report layout, LP). Add more +# pages once the baseline is stable in CI. +# +# Run just these: bin/rails test test/visual/pages_visual_test.rb +class PagesVisualTest < ApplicationVisualTestCase + test 'dashboard' do + visit_with_auth '/', 'hatsuno' + assert_selector '.page-body' + capture('dashboard') + end + + test 'report show' do + report = reports(:report1) + visit_with_auth "/reports/#{report.id}", 'komagata' + assert_selector '.page-content' + # The mentor "直近の日報" side panel lists the author's recent reports; + # under frozen time their order/last-card height is non-deterministic, so + # mask that panel (its card CSS is covered by the main report body). + capture('report_show', extra_mask: ['#side-tabs-content-1']) + end + + test 'report form' do + visit_with_auth '/reports/new', 'komagata' + assert_selector 'form' + capture('report_form') + end + + test 'users index' do + visit_with_auth '/users', 'komagata' + assert_selector '.users-item', minimum: 1 + capture('users_index') + end + + test 'user profile' do + user = users(:hatsuno) + visit_with_auth "/users/#{user.id}", 'komagata' + assert_selector '.user-profile, .page-content' + capture('user_profile') + end + + test 'lp practices' do + visit '/practices' + assert_selector '.lp-header' + capture('lp_practices') + end +end